TestingQuality EngineeringLatest

Testing at scale: unit tests, Cypress and SonarCloud

December 20, 2024 • 15 min read

Tests are less about coverage numbers and more about the habits that keep bugs out of prod.

The foundation

  • Unit tests – Jest, on real payment logic rather than toy examples
  • The pyramid – roughly 70% unit, 20% integration, 10% E2E
  • Cypress – the few journeys that actually matter, end to end
  • API tests – checking the front end and back end agree

The automation around it

  • Git hooks – catch problems on commit, before CI even sees them
  • SonarCloud – static analysis on every change
  • GitHub Actions – the gate that runs it all on a PR
  • Metrics – coverage, maintainability, reliability

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 testing pyramid in practice

Unit Tests (70%)
• Fast execution
• Individual functions
• High coverage
• Immediate feedback
Integration Tests (20%)
• API endpoints
• Database interactions
• Service communication
• Real data flows
E2E Tests (10%)
• User journeys
• Browser automation
• Critical paths
• Production-like

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.

Unit tests with Jest

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');
    });
  });
});

Cypress for end-to-end

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');
  });
});

Git hooks as the first gate

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'
  ]
};

SonarCloud in the pipeline

# 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.

Share: