December 20, 2024 • 15 min read
Tests are less about coverage numbers and more about the habits that keep bugs out of prod.
Coverage percentage is the number everyone quotes, and it's mostly the wrong one to chase. What actually keeps bugs out of prod is the habit: a test gets written when the bug gets fixed, the hooks run before you push, and the PR doesn't merge until the gate is green. I've set this up on a few production systems, and the parts that mattered weren't clever tests. They were making the boring checks automatic so nobody had to remember them.
The shape is the point: lots of cheap, fast tests at the bottom, very few slow ones at the top. Flip it and your suite takes ten minutes to run and nobody waits for it.
A unit test should be fast, isolated, and tell you exactly what broke when it fails. Here's how I tend to lay one out for something with real business logic, like payments:
// PaymentProcessor.test.js - Comprehensive unit testing example
import PaymentProcessor from '../PaymentProcessor';
import { mockPaymentGateway, mockAuditLogger } from '../__mocks__';
describe('PaymentProcessor', () => {
let paymentProcessor;
let mockGateway;
let mockLogger;
beforeEach(() => {
mockGateway = mockPaymentGateway();
mockLogger = mockAuditLogger();
paymentProcessor = new PaymentProcessor(mockGateway, mockLogger);
});
describe('processPayment', () => {
it('should successfully process a valid payment', async () => {
// Arrange
const paymentRequest = {
amount: 99.99,
currency: 'AUD',
cardToken: 'tok_1234567890',
customerId: 'cus_test123'
};
mockGateway.charge.mockResolvedValue({
id: 'charge_test123',
status: 'succeeded',
amount: 9999
});
// Act
const result = await paymentProcessor.processPayment(paymentRequest);
// Assert
expect(result.success).toBe(true);
expect(result.chargeId).toBe('charge_test123');
expect(mockLogger.logPaymentSuccess).toHaveBeenCalled();
});
it('should handle payment failures gracefully', async () => {
mockGateway.charge.mockRejectedValue({
type: 'card_error',
code: 'card_declined'
});
const result = await paymentProcessor.processPayment({
amount: 50.00,
currency: 'AUD'
});
expect(result.success).toBe(false);
expect(result.error.type).toBe('card_error');
});
});
});E2E tests are expensive to run and easy to make flaky, so I save them for the journeys that lose money if they break. Checkout is the obvious one:
// cypress/e2e/checkout-flow.cy.js
describe('E-commerce Checkout Flow', () => {
beforeEach(() => {
cy.task('db:seed');
cy.intercept('POST', '/api/payments/process', {
fixture: 'payment-success.json'
}).as('processPayment');
});
it('completes full purchase journey', () => {
cy.visit('/products/wireless-headphones');
cy.get('[data-testid="add-to-cart-btn"]').click();
cy.get('[data-testid="checkout-btn"]').click();
// Fill shipping information
cy.get('input[name="email"]').type('test@example.com');
cy.get('input[name="address"]').type('123 Test Street');
// Fill payment information
cy.get('input[name="cardNumber"]').type('4242424242424242');
cy.get('input[name="expiryDate"]').type('12/25');
cy.get('[data-testid="complete-purchase-btn"]').click();
cy.wait('@processPayment');
cy.url().should('include', '/order-confirmation');
cy.get('[data-testid="order-confirmation"]')
.should('contain', 'Order Confirmed');
});
});The cheapest place to catch a problem is before it leaves the developer's machine. A pre-commit hook running lint, types and related tests does most of that work:
# .husky/pre-commit
#!/usr/bin/env sh
echo "🔍 Running pre-commit checks..."
# Lint staged files
npx lint-staged
# Type checking
npx tsc --noEmit
# Security audit
npm audit --audit-level moderate
echo "✅ Pre-commit checks completed!"
# package.json scripts
{
"scripts": {
"test:unit": "jest --testPathPattern=__tests__/unit",
"test:integration": "jest --testPathPattern=__tests__/integration",
"test:e2e": "cypress run",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"prepare": "husky install"
}
}
# lint-staged.config.js
module.exports = {
'*.{js,jsx,ts,tsx}': [
'eslint --fix',
'prettier --write',
'jest --findRelatedTests --passWithNoTests'
]
};# sonar-project.properties
sonar.projectKey=quality-engineering-example
sonar.organization=handy-hasan
sonar.sources=src
sonar.tests=src
sonar.test.inclusions=**/*.test.js,**/*.spec.js
sonar.javascript.lcov.reportPaths=coverage/lcov.info
# GitHub Actions Quality Pipeline
name: Quality Analysis
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
- name: SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}At The Good Guys this setup cut our production bugs by about 85%, but the number isn't really the point. What changed was that people stopped being nervous about shipping on a Friday. The hooks and the gate did the remembering, so a release stopped feeling like a gamble. That's the payoff, and it's worth the bit of setup time up front.