Unit Testing: How I Stopped Breaking Production on Fridays
Three months into my job at Microsoft, I deployed what seemed like a harmless one-line fix on a Friday afternoon. By Monday morning, our Azure DevOps service was partially down, affecting thousands of developers worldwide. That one line had broken a critical authentication flow, and I had no tests to catch it.
My manager didn't fire me, but she did say something that changed my career: "Maya, code without tests is just code that hasn't broken yet." That week, I learned to write tests, and I haven't deployed on Friday without them since.
The Friday Afternoon Disaster
Here's the innocent-looking code that brought down production:
// The "harmless" change
function validateUserPermissions(user, resource) {
// Changed from === to == (seemed innocent enough)
if (user.role == 'admin') { // This was the bug!
return true;
}
return user.permissions.includes(resource);
}
// What I didn't realize:
// user.role was sometimes the string "admin"
// user.role was sometimes the number 1 (for admin users)
// The == operator made 1 == 'admin' evaluate to false
// Thousands of admin users suddenly couldn't access anything
If I'd had a test, this would have been caught immediately:
// The test that would have saved my weekend
describe('validateUserPermissions', () => {
it('should grant access to admin users regardless of role type', () => {
const stringAdminUser = { role: 'admin', permissions: [] };
const numericAdminUser = { role: 1, permissions: [] };
expect(validateUserPermissions(stringAdminUser, 'any-resource')).toBe(true);
expect(validateUserPermissions(numericAdminUser, 'any-resource')).toBe(true);
});
});
Testing: The Coffee Quality Control Analogy
I finally understood testing when I thought about my favorite coffee shops. Good baristas taste every shot before serving it. They don't just hope the espresso is good - they verify it.
Unit testing is the same concept:
- Unit: Individual coffee shot (function)
- Test: Taste test (verification)
- Assertion: "This tastes right" (expected outcome)
- Mock: Using decaf when testing late at night (fake dependencies)
Setting Up Testing in JavaScript (Jest)
Let's start with a simple payment calculator for our coffee shop:
// src/paymentCalculator.js
class PaymentCalculator {
constructor(taxRate = 0.101) { // Seattle tax rate
this.taxRate = taxRate;
}
calculateSubtotal(items) {
if (!Array.isArray(items) || items.length === 0) {
return 0;
}
return items.reduce((total, item) => {
if (!item.price || !item.quantity) {
throw new Error('Invalid item: missing price or quantity');
}
return total + (item.price * item.quantity);
}, 0);
}
calculateTax(subtotal) {
if (subtotal < 0) {
throw new Error('Subtotal cannot be negative');
}
return Math.round(subtotal * this.taxRate * 100) / 100;
}
calculateTotal(items, discountPercent = 0) {
const subtotal = this.calculateSubtotal(items);
const discount = (subtotal * discountPercent) / 100;
const discountedSubtotal = subtotal - discount;
const tax = this.calculateTax(discountedSubtotal);
return Math.round((discountedSubtotal + tax) * 100) / 100;
}
applyLoyaltyDiscount(total, loyaltyPoints) {
const maxDiscount = total * 0.5; // Max 50% discount
const pointsValue = loyaltyPoints * 0.01; // 1 point = $0.01
const discount = Math.min(pointsValue, maxDiscount);
return Math.round((total - discount) * 100) / 100;
}
}
module.exports = PaymentCalculator;
Now let's test it thoroughly:
// tests/paymentCalculator.test.js
const PaymentCalculator = require('../src/paymentCalculator');
describe('PaymentCalculator', () => {
let calculator;
beforeEach(() => {
calculator = new PaymentCalculator();
});
describe('calculateSubtotal', () => {
it('should calculate subtotal for valid items', () => {
const items = [
{ price: 4.50, quantity: 2 }, // 2 lattes
{ price: 3.25, quantity: 1 } // 1 muffin
];
const result = calculator.calculateSubtotal(items);
expect(result).toBe(12.25);
});
it('should return 0 for empty array', () => {
expect(calculator.calculateSubtotal([])).toBe(0);
});
it('should return 0 for null or undefined', () => {
expect(calculator.calculateSubtotal(null)).toBe(0);
expect(calculator.calculateSubtotal(undefined)).toBe(0);
});
it('should throw error for items with missing price', () => {
const items = [{ quantity: 1 }]; // Missing price
expect(() => {
calculator.calculateSubtotal(items);
}).toThrow('Invalid item: missing price or quantity');
});
it('should throw error for items with missing quantity', () => {
const items = [{ price: 4.50 }]; // Missing quantity
expect(() => {
calculator.calculateSubtotal(items);
}).toThrow('Invalid item: missing price or quantity');
});
});
describe('calculateTax', () => {
it('should calculate tax correctly', () => {
const result = calculator.calculateTax(100);
expect(result).toBe(10.10); // 100 * 0.101
});
it('should round tax to 2 decimal places', () => {
const result = calculator.calculateTax(12.34);
expect(result).toBe(1.25); // 12.34 * 0.101 = 1.2534, rounded to 1.25
});
it('should handle zero subtotal', () => {
expect(calculator.calculateTax(0)).toBe(0);
});
it('should throw error for negative subtotal', () => {
expect(() => {
calculator.calculateTax(-10);
}).toThrow('Subtotal cannot be negative');
});
});
describe('calculateTotal', () => {
it('should calculate total with tax', () => {
const items = [{ price: 10, quantity: 1 }];
const result = calculator.calculateTotal(items);
expect(result).toBe(11.01); // 10 + (10 * 0.101)
});
it('should apply discount before tax', () => {
const items = [{ price: 100, quantity: 1 }];
const result = calculator.calculateTotal(items, 10); // 10% discount
// 100 - 10 (discount) = 90
// 90 + 9.09 (tax) = 99.09
expect(result).toBe(99.09);
});
it('should handle 100% discount', () => {
const items = [{ price: 100, quantity: 1 }];
const result = calculator.calculateTotal(items, 100);
expect(result).toBe(0);
});
});
describe('applyLoyaltyDiscount', () => {
it('should apply loyalty points as discount', () => {
const result = calculator.applyLoyaltyDiscount(20, 500); // 500 points = $5
expect(result).toBe(15.00);
});
it('should cap discount at 50% of total', () => {
const result = calculator.applyLoyaltyDiscount(20, 5000); // 5000 points = $50
expect(result).toBe(10.00); // Max 50% discount = $10 off
});
it('should handle zero loyalty points', () => {
const result = calculator.applyLoyaltyDiscount(20, 0);
expect(result).toBe(20.00);
});
});
});
Testing in Python with pytest
Now let's look at the same concepts in Python:
# src/payment_calculator.py
from typing import List, Dict, Union
import math
class PaymentCalculator:
def __init__(self, tax_rate: float = 0.101):
self.tax_rate = tax_rate
def calculate_subtotal(self, items: List[Dict[str, Union[float, int]]]) -> float:
"""Calculate subtotal from list of items."""
if not items:
return 0.0
total = 0
for item in items:
if 'price' not in item or 'quantity' not in item:
raise ValueError("Invalid item: missing price or quantity")
if item['price'] < 0 or item['quantity'] < 0:
raise ValueError("Price and quantity must be non-negative")
total += item['price'] * item['quantity']
return round(total, 2)
def calculate_tax(self, subtotal: float) -> float:
"""Calculate tax on subtotal."""
if subtotal < 0:
raise ValueError("Subtotal cannot be negative")
return round(subtotal * self.tax_rate, 2)
def calculate_total(self, items: List[Dict], discount_percent: float = 0) -> float:
"""Calculate total with discount and tax."""
subtotal = self.calculate_subtotal(items)
discount = (subtotal * discount_percent) / 100
discounted_subtotal = subtotal - discount
tax = self.calculate_tax(discounted_subtotal)
return round(discounted_subtotal + tax, 2)
def apply_loyalty_discount(self, total: float, loyalty_points: int) -> float:
"""Apply loyalty points as discount."""
max_discount = total * 0.5 # Max 50% discount
points_value = loyalty_points * 0.01 # 1 point = $0.01
discount = min(points_value, max_discount)
return round(total - discount, 2)
# tests/test_payment_calculator.py
import pytest
from src.payment_calculator import PaymentCalculator
class TestPaymentCalculator:
@pytest.fixture
def calculator(self):
"""Create a fresh calculator instance for each test."""
return PaymentCalculator()
def test_calculate_subtotal_valid_items(self, calculator):
"""Test subtotal calculation with valid items."""
items = [
{'price': 4.50, 'quantity': 2}, # 2 lattes
{'price': 3.25, 'quantity': 1} # 1 muffin
]
result = calculator.calculate_subtotal(items)
assert result == 12.25
def test_calculate_subtotal_empty_list(self, calculator):
"""Test subtotal with empty items list."""
assert calculator.calculate_subtotal([]) == 0.0
def test_calculate_subtotal_missing_price(self, calculator):
"""Test error handling for missing price."""
items = [{'quantity': 1}] # Missing price
with pytest.raises(ValueError, match="Invalid item: missing price or quantity"):
calculator.calculate_subtotal(items)
def test_calculate_subtotal_missing_quantity(self, calculator):
"""Test error handling for missing quantity."""
items = [{'price': 4.50}] # Missing quantity
with pytest.raises(ValueError, match="Invalid item: missing price or quantity"):
calculator.calculate_subtotal(items)
def test_calculate_subtotal_negative_price(self, calculator):
"""Test error handling for negative price."""
items = [{'price': -4.50, 'quantity': 1}]
with pytest.raises(ValueError, match="Price and quantity must be non-negative"):
calculator.calculate_subtotal(items)
def test_calculate_tax_normal(self, calculator):
"""Test normal tax calculation."""
result = calculator.calculate_tax(100)
assert result == 10.10
def test_calculate_tax_rounding(self, calculator):
"""Test tax calculation with rounding."""
result = calculator.calculate_tax(12.34)
assert result == 1.25 # 12.34 * 0.101 = 1.2534, rounded to 1.25
def test_calculate_tax_zero(self, calculator):
"""Test tax calculation with zero subtotal."""
assert calculator.calculate_tax(0) == 0
def test_calculate_tax_negative_subtotal(self, calculator):
"""Test error handling for negative subtotal."""
with pytest.raises(ValueError, match="Subtotal cannot be negative"):
calculator.calculate_tax(-10)
def test_calculate_total_with_tax(self, calculator):
"""Test total calculation with tax."""
items = [{'price': 10, 'quantity': 1}]
result = calculator.calculate_total(items)
assert result == 11.01 # 10 + (10 * 0.101)
def test_calculate_total_with_discount(self, calculator):
"""Test total calculation with discount applied before tax."""
items = [{'price': 100, 'quantity': 1}]
result = calculator.calculate_total(items, 10) # 10% discount
# 100 - 10 (discount) = 90
# 90 + 9.09 (tax) = 99.09
assert result == 99.09
@pytest.mark.parametrize("points,expected", [
(500, 15.00), # 500 points = $5 discount
(1000, 10.00), # 1000 points = $10 discount (50% cap)
(5000, 10.00), # 5000 points = $50, but capped at 50% = $10
(0, 20.00), # No points = no discount
])
def test_apply_loyalty_discount_parametrized(self, calculator, points, expected):
"""Test loyalty discount with various point values."""
result = calculator.apply_loyalty_discount(20, points)
assert result == expected
def test_custom_tax_rate(self):
"""Test calculator with custom tax rate."""
calculator = PaymentCalculator(tax_rate=0.08) # 8% tax
result = calculator.calculate_tax(100)
assert result == 8.00
Mocking: Testing with Fake Dependencies
Real applications have dependencies - APIs, databases, external services. We mock these to test our code in isolation:
// src/paymentService.js
const stripe = require('stripe');
const emailService = require('./emailService');
const database = require('./database');
class PaymentService {
constructor(stripeClient, emailClient, db) {
this.stripe = stripeClient;
this.email = emailClient;
this.db = db;
}
async processPayment(paymentData) {
try {
// Validate payment data
if (!paymentData.amount || paymentData.amount <= 0) {
throw new Error('Invalid payment amount');
}
// Save to database first
const payment = await this.db.payments.create({
amount: paymentData.amount,
customer_id: paymentData.customerId,
status: 'pending'
});
// Process with Stripe
const charge = await this.stripe.charges.create({
amount: paymentData.amount * 100, // Stripe uses cents
currency: 'usd',
customer: paymentData.customerId,
description: paymentData.description
});
// Update payment status
await this.db.payments.update(payment.id, {
status: 'completed',
stripe_charge_id: charge.id
});
// Send confirmation email
await this.email.sendPaymentConfirmation({
to: paymentData.customerEmail,
amount: paymentData.amount,
chargeId: charge.id
});
return {
success: true,
paymentId: payment.id,
chargeId: charge.id
};
} catch (error) {
// Update payment status to failed if we created a record
if (payment && payment.id) {
await this.db.payments.update(payment.id, {
status: 'failed',
error_message: error.message
});
}
throw error;
}
}
}
module.exports = PaymentService;
// tests/paymentService.test.js
const PaymentService = require('../src/paymentService');
describe('PaymentService', () => {
let paymentService;
let mockStripe;
let mockEmail;
let mockDb;
beforeEach(() => {
// Create mock objects
mockStripe = {
charges: {
create: jest.fn()
}
};
mockEmail = {
sendPaymentConfirmation: jest.fn()
};
mockDb = {
payments: {
create: jest.fn(),
update: jest.fn()
}
};
paymentService = new PaymentService(mockStripe, mockEmail, mockDb);
});
describe('processPayment', () => {
const validPaymentData = {
amount: 99.99,
customerId: 'cus_123',
customerEmail: 'maya@example.com',
description: 'Coffee order'
};
it('should process payment successfully', async () => {
// Setup mocks
mockDb.payments.create.mockResolvedValue({ id: 'pay_123' });
mockStripe.charges.create.mockResolvedValue({ id: 'ch_123' });
mockDb.payments.update.mockResolvedValue();
mockEmail.sendPaymentConfirmation.mockResolvedValue();
const result = await paymentService.processPayment(validPaymentData);
// Verify result
expect(result).toEqual({
success: true,
paymentId: 'pay_123',
chargeId: 'ch_123'
});
// Verify database calls
expect(mockDb.payments.create).toHaveBeenCalledWith({
amount: 99.99,
customer_id: 'cus_123',
status: 'pending'
});
expect(mockDb.payments.update).toHaveBeenCalledWith('pay_123', {
status: 'completed',
stripe_charge_id: 'ch_123'
});
// Verify Stripe call
expect(mockStripe.charges.create).toHaveBeenCalledWith({
amount: 9999, // 99.99 * 100
currency: 'usd',
customer: 'cus_123',
description: 'Coffee order'
});
// Verify email sent
expect(mockEmail.sendPaymentConfirmation).toHaveBeenCalledWith({
to: 'maya@example.com',
amount: 99.99,
chargeId: 'ch_123'
});
});
it('should handle Stripe failure', async () => {
mockDb.payments.create.mockResolvedValue({ id: 'pay_123' });
mockStripe.charges.create.mockRejectedValue(new Error('Card declined'));
await expect(paymentService.processPayment(validPaymentData))
.rejects.toThrow('Card declined');
// Should update payment status to failed
expect(mockDb.payments.update).toHaveBeenCalledWith('pay_123', {
status: 'failed',
error_message: 'Card declined'
});
// Should not send email
expect(mockEmail.sendPaymentConfirmation).not.toHaveBeenCalled();
});
it('should reject invalid payment amount', async () => {
const invalidData = { ...validPaymentData, amount: -10 };
await expect(paymentService.processPayment(invalidData))
.rejects.toThrow('Invalid payment amount');
// Should not call any external services
expect(mockDb.payments.create).not.toHaveBeenCalled();
expect(mockStripe.charges.create).not.toHaveBeenCalled();
expect(mockEmail.sendPaymentConfirmation).not.toHaveBeenCalled();
});
});
});
Test-Driven Development (TDD)
After my production incident, I started practicing TDD - writing tests before code:
// Step 1: Write a failing test
describe('CoffeeLoyaltyProgram', () => {
it('should calculate points earned from purchase', () => {
const program = new CoffeeLoyaltyProgram();
const points = program.calculatePointsEarned(25.50);
expect(points).toBe(26); // 1 point per dollar + bonus point
});
});
// Step 2: Write minimal code to make it pass
class CoffeeLoyaltyProgram {
calculatePointsEarned(amount) {
return Math.floor(amount) + 1; // Base points + bonus
}
}
// Step 3: Refactor and add more tests
describe('CoffeeLoyaltyProgram', () => {
let program;
beforeEach(() => {
program = new CoffeeLoyaltyProgram();
});
it('should calculate points earned from purchase', () => {
expect(program.calculatePointsEarned(25.50)).toBe(26);
});
it('should give bonus points for purchases over $20', () => {
expect(program.calculatePointsEarned(20.00)).toBe(20); // No bonus
expect(program.calculatePointsEarned(20.01)).toBe(22); // Bonus
});
it('should handle zero amount', () => {
expect(program.calculatePointsEarned(0)).toBe(0);
});
});
// Step 4: Implement full logic
class CoffeeLoyaltyProgram {
calculatePointsEarned(amount) {
if (amount <= 0) return 0;
const basePoints = Math.floor(amount);
const bonusPoints = amount > 20 ? 2 : 1;
return basePoints + bonusPoints;
}
}
Integration Testing
Testing how components work together:
// tests/integration/orderFlow.test.js
const request = require('supertest');
const app = require('../../src/app');
const database = require('../../src/database');
describe('Order Flow Integration', () => {
beforeEach(async () => {
await database.clearTestData();
await database.seedTestData();
});
afterEach(async () => {
await database.clearTestData();
});
it('should complete full order process', async () => {
// Create customer
const customerResponse = await request(app)
.post('/api/customers')
.send({
name: 'Maya Chen',
email: 'maya@example.com'
})
.expect(201);
const customerId = customerResponse.body.customer.id;
// Create order
const orderResponse = await request(app)
.post('/api/orders')
.send({
customerId,
items: [
{ productId: 'latte', quantity: 2 },
{ productId: 'muffin', quantity: 1 }
]
})
.expect(201);
const orderId = orderResponse.body.order.id;
// Process payment
const paymentResponse = await request(app)
.post(`/api/orders/${orderId}/payment`)
.send({
paymentMethod: 'credit_card',
cardToken: 'tok_visa'
})
.expect(200);
expect(paymentResponse.body.status).toBe('completed');
// Verify order status updated
const orderCheck = await request(app)
.get(`/api/orders/${orderId}`)
.expect(200);
expect(orderCheck.body.order.status).toBe('paid');
});
});
Testing Best Practices from Production
Test Structure (Arrange, Act, Assert)
it('should apply student discount correctly', () => {
// Arrange - Set up test data
const calculator = new PaymentCalculator();
const items = [{ price: 20, quantity: 1 }];
const discountPercent = 15; // Student discount
// Act - Execute the code under test
const result = calculator.calculateTotal(items, discountPercent);
// Assert - Verify the outcome
expect(result).toBe(18.72); // 20 - 3 (discount) + 1.72 (tax)
});
Descriptive Test Names
// Bad test names
it('should work', () => {});
it('test payment', () => {});
it('should return true', () => {});
// Good test names
it('should reject payment when amount is negative', () => {});
it('should send confirmation email after successful payment', () => {});
it('should apply loyalty discount before calculating tax', () => {});
Test Data Management
// tests/helpers/testData.js
const createTestCustomer = (overrides = {}) => ({
id: 'cust_123',
name: 'Test Customer',
email: 'test@example.com',
loyaltyPoints: 100,
...overrides
});
const createTestOrder = (overrides = {}) => ({
id: 'ord_456',
customerId: 'cust_123',
items: [
{ productId: 'latte', quantity: 1, price: 4.50 }
],
status: 'pending',
...overrides
});
module.exports = { createTestCustomer, createTestOrder };
Code Coverage and Quality
// jest.config.js
module.exports = {
testEnvironment: 'node',
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/config/**',
'!src/migrations/**'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
Testing Anti-Patterns (Don't Do This)
Testing Implementation Details
// Bad - testing internal methods
it('should call validateCardNumber method', () => {
const spy = jest.spyOn(paymentService, 'validateCardNumber');
paymentService.processPayment(paymentData);
expect(spy).toHaveBeenCalled();
});
// Good - testing behavior
it('should reject payment with invalid card number', () => {
const invalidPayment = { ...paymentData, cardNumber: '1234' };
expect(() => paymentService.processPayment(invalidPayment))
.toThrow('Invalid card number');
});
Overly Complex Tests
// Bad - doing too much in one test
it('should handle complete order flow with multiple edge cases', () => {
// 50 lines of setup
// Testing 10 different scenarios
// Multiple assertions that could fail for different reasons
});
// Good - focused tests
it('should calculate tax on discounted amount', () => {
// Simple, focused test
});
it('should apply loyalty discount after tax calculation', () => {
// Another focused test
});
Continuous Integration with Tests
# .github/workflows/test.yml
name: Test Suite
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:13
env:
POSTGRES_PASSWORD: password
POSTGRES_DB: test_db
ports:
- 5432:5432
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '16'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run unit tests
run: npm test
env:
DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db
- name: Run integration tests
run: npm run test:integration
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
Final Thoughts: Tests as Documentation
That Friday afternoon disaster taught me something crucial: tests aren't just about catching bugs. They're living documentation of how your code should behave. When I read my payment processing tests, I understand exactly what the business rules are, what edge cases we handle, and what could go wrong.
Six months after implementing comprehensive testing at Microsoft, our team's confidence skyrocketed. We deployed more frequently, refactored fearlessly, and actually enjoyed Friday afternoon deployments (okay, that's a stretch, but we weren't terrified).
Whether you're building a coffee shop POS system or processing millions in payments, tests give you the confidence to change code without fear. Start small - test one function, then another. Before you know it, you'll have a safety net that catches problems before your users do.
Remember: code without tests is just code that hasn't broken yet. And in production, everything breaks eventually.
Writing this from Seattle Public Library's 10th floor, where I can see the Space Needle and our deployment dashboard showing all green tests. Share your testing wins (or disasters) @maya_codes_pnw - we've all been there! 🧪✅
Add Comment
No comments yet. Be the first to comment!