Back to Blog

Testing Strategy for Production Software: Unit, Integration, E2E, and Mutation Testing

Build a practical testing strategy for production software. Unit tests, integration tests, E2E with Playwright, test coverage targets, and mutation testing to f

Viprasol Tech Team
July 10, 2026
13 min read

Testing Strategy for Production Software: Unit, Integration, E2E, and Mutation Testing

The testing pyramid is universally known and consistently ignored. Teams write too many E2E tests that take 40 minutes and break on CI due to timing issues. Or they write no tests, merge to production, and find out about bugs from customers. Neither extreme works.

A practical testing strategy is about coverage with confidence β€” knowing that your tests actually protect against regressions, run fast enough that developers don't skip them, and focus effort where bugs are most expensive.

This post covers the full stack: Vitest for unit/integration tests, Playwright for E2E, MSW for API mocking, and Stryker for mutation testing β€” the technique that tells you whether your tests are actually testing anything.


The Testing Pyramid (Adjusted for 2026)

The classic "lots of unit tests, fewer integration, minimal E2E" is roughly right but needs refinement:

LayerCountSpeedConfidenceCost
Unit (pure functions, utils)Many (200–500+)<10ms eachLow–mediumLow
Integration (services + DB)Medium (50–150)50–500ms eachHighMedium
E2E (critical user flows)Few (10–30)2–30s eachVery highHigh
Contract (API boundaries)Medium (20–60)50–200ms eachHighMedium

The real insight: integration tests give the best ROI. They test real code against real (test) databases, catch SQL bugs, ORM misconfigurations, and service boundary issues. Unit tests alone miss 60%+ of production bugs.


Vitest Setup: The 2026 Standard

Vitest is the right choice for most TypeScript projects β€” it's faster than Jest, natively supports ESM, and shares Vite's config.

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';

export default defineConfig({
  test: {
    globals: true,       // No need to import describe/it/expect
    environment: 'node',
    setupFiles: ['./tests/setup.ts'],

    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov', 'html'],
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.d.ts', 'src/**/types.ts', 'src/index.ts'],

      // Minimum thresholds β€” CI fails if below these
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
    },

    // Parallelism
    pool: 'threads',
    poolOptions: {
      threads: { singleThread: false, maxThreads: 4 },
    },
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, './src'),
    },
  },
});
// tests/setup.ts
import { beforeAll, afterAll, beforeEach } from 'vitest';
import { db } from '@/db';

beforeAll(async () => {
  // Run migrations against test database
  await db.migrate.latest();
});

afterAll(async () => {
  await db.destroy();
});

beforeEach(async () => {
  // Wrap each test in a transaction and roll back
  // (much faster than truncating tables)
  await db.raw('BEGIN');
});

afterEach(async () => {
  await db.raw('ROLLBACK');
});

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Unit Tests: Pure Functions and Business Logic

Unit tests shine on pure functions β€” deterministic, no side effects, no database.

// src/lib/pricing.ts
export function calculateTieredPrice(
  quantity: number,
  tiers: Array<{ upTo: number | null; unitPrice: number }>,
): number {
  let total = 0;
  let remaining = quantity;

  for (let i = 0; i < tiers.length; i++) {
    const tier = tiers[i];
    const prevUpTo = i === 0 ? 0 : (tiers[i - 1].upTo ?? Infinity);
    const tierCapacity = tier.upTo !== null ? tier.upTo - prevUpTo : Infinity;
    const unitsInTier = Math.min(remaining, tierCapacity);

    total += unitsInTier * tier.unitPrice;
    remaining -= unitsInTier;

    if (remaining <= 0) break;
  }

  return total;
}
// tests/unit/pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateTieredPrice } from '@/lib/pricing';

const STANDARD_TIERS = [
  { upTo: 100, unitPrice: 0.10 },
  { upTo: 1000, unitPrice: 0.08 },
  { upTo: null, unitPrice: 0.05 },
];

describe('calculateTieredPrice', () => {
  it('calculates first tier correctly', () => {
    expect(calculateTieredPrice(50, STANDARD_TIERS)).toBe(5.00);
  });

  it('spans multiple tiers', () => {
    // 100 Γ— $0.10 = $10 + 400 Γ— $0.08 = $32 β†’ $42
    expect(calculateTieredPrice(500, STANDARD_TIERS)).toBe(42.00);
  });

  it('handles exact tier boundary', () => {
    expect(calculateTieredPrice(100, STANDARD_TIERS)).toBe(10.00);
  });

  it('handles unlimited final tier', () => {
    // 100 Γ— $0.10 + 900 Γ— $0.08 + 500 Γ— $0.05 = $10 + $72 + $25 = $107
    expect(calculateTieredPrice(1500, STANDARD_TIERS)).toBe(107.00);
  });

  it('returns 0 for quantity 0', () => {
    expect(calculateTieredPrice(0, STANDARD_TIERS)).toBe(0);
  });

  it('handles single-tier pricing', () => {
    const flatTiers = [{ upTo: null, unitPrice: 0.05 }];
    expect(calculateTieredPrice(200, flatTiers)).toBe(10.00);
  });
});

Integration Tests: Services with Real Database

Integration tests test the full service layer against a real PostgreSQL database (test instance). This catches SQL errors, constraint violations, ORM bugs, and business logic that crosses multiple tables.

// src/services/orders.ts
export async function createOrder(
  userId: string,
  items: OrderItem[],
): Promise<Order> {
  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  return db.transaction(async (trx) => {
    // Check inventory
    for (const item of items) {
      const [product] = await trx('products')
        .where({ id: item.productId })
        .select('inventory', 'name')
        .forUpdate(); // Lock row

      if (!product) throw new Error(`Product ${item.productId} not found`);
      if (product.inventory < item.quantity) {
        throw new InsufficientInventoryError(product.name, product.inventory);
      }
    }

    // Deduct inventory
    for (const item of items) {
      await trx('products')
        .where({ id: item.productId })
        .decrement('inventory', item.quantity);
    }

    // Create order
    const [order] = await trx('orders')
      .insert({ userId, total, status: 'pending' })
      .returning('*');

    // Create line items
    await trx('order_items').insert(
      items.map((item) => ({ orderId: order.id, ...item })),
    );

    return order;
  });
}
// tests/integration/orders.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createOrder } from '@/services/orders';
import { db } from '@/db';
import { createTestUser, createTestProduct } from '../helpers/factories';

describe('createOrder', () => {
  let userId: string;
  let product: { id: string; inventory: number };

  beforeEach(async () => {
    // setup.ts wraps in transaction β€” these will be rolled back
    userId = await createTestUser();
    product = await createTestProduct({ price: 29.99, inventory: 10 });
  });

  it('creates an order and deducts inventory', async () => {
    const order = await createOrder(userId, [
      { productId: product.id, quantity: 3, price: 29.99 },
    ]);

    expect(order.total).toBe(89.97);
    expect(order.status).toBe('pending');

    const [updated] = await db('products').where({ id: product.id }).select('inventory');
    expect(updated.inventory).toBe(7); // 10 - 3
  });

  it('throws InsufficientInventoryError when stock too low', async () => {
    await expect(
      createOrder(userId, [
        { productId: product.id, quantity: 15, price: 29.99 }, // Only 10 in stock
      ]),
    ).rejects.toThrow('InsufficientInventoryError');

    // Verify inventory was NOT modified (transaction rolled back)
    const [unchanged] = await db('products').where({ id: product.id }).select('inventory');
    expect(unchanged.inventory).toBe(10);
  });

  it('handles multi-item orders atomically', async () => {
    const product2 = await createTestProduct({ price: 9.99, inventory: 5 });

    const order = await createOrder(userId, [
      { productId: product.id, quantity: 2, price: 29.99 },
      { productId: product2.id, quantity: 2, price: 9.99 },
    ]);

    expect(order.total).toBeCloseTo(79.96, 2);
    const items = await db('order_items').where({ orderId: order.id });
    expect(items).toHaveLength(2);
  });
});

πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

API Tests with MSW (Mock Service Worker)

When your service calls external APIs (Stripe, SendGrid, Twilio), mock them at the network level with MSW:

// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  // Mock Stripe customer creation
  http.post('https://api.stripe.com/v1/customers', () => {
    return HttpResponse.json({
      id: 'cus_test_mock',
      object: 'customer',
      email: 'test@example.com',
    });
  }),

  // Mock SendGrid email
  http.post('https://api.sendgrid.com/v3/mail/send', () => {
    return new HttpResponse(null, { status: 202 });
  }),

  // Simulate Stripe error
  http.post('https://api.stripe.com/v1/payment_intents', () => {
    return HttpResponse.json(
      { error: { type: 'card_error', code: 'card_declined' } },
      { status: 402 },
    );
  }),
];
// tests/setup.ts (add MSW setup)
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';

const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Playwright E2E Tests

E2E tests should cover critical business flows only β€” checkout, authentication, core feature activation. Not every page.

// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Checkout flow', () => {
  test.beforeEach(async ({ page }) => {
    // Seed test data via API (faster than UI setup)
    await page.request.post('/api/test/seed', {
      data: { scenario: 'checkout_ready_user' },
    });
  });

  test('completes checkout with valid card', async ({ page }) => {
    await page.goto('/shop');

    // Add product to cart
    await page.getByTestId('product-card-001').getByRole('button', { name: 'Add to Cart' }).click();
    await expect(page.getByTestId('cart-badge')).toHaveText('1');

    // Go to checkout
    await page.getByRole('link', { name: 'Cart' }).click();
    await page.getByRole('button', { name: 'Checkout' }).click();

    // Fill Stripe Payment Element (test mode)
    const frame = page.frameLocator('[title="Secure payment input frame"]');
    await frame.getByRole('textbox', { name: 'Card number' }).fill('4242424242424242');
    await frame.getByRole('textbox', { name: 'Expiration' }).fill('12/28');
    await frame.getByRole('textbox', { name: 'CVC' }).fill('123');

    await page.getByRole('button', { name: 'Pay now' }).click();

    // Wait for success redirect
    await page.waitForURL('**/order-confirmation/**');
    await expect(page.getByRole('heading', { name: 'Order Confirmed' })).toBeVisible();
    await expect(page.getByTestId('order-id')).toBeVisible();
  });

  test('shows error on declined card', async ({ page }) => {
    await page.goto('/checkout');

    const frame = page.frameLocator('[title="Secure payment input frame"]');
    await frame.getByRole('textbox', { name: 'Card number' }).fill('4000000000000002'); // Decline
    await frame.getByRole('textbox', { name: 'Expiration' }).fill('12/28');
    await frame.getByRole('textbox', { name: 'CVC' }).fill('123');

    await page.getByRole('button', { name: 'Pay now' }).click();

    await expect(page.getByRole('alert')).toContainText('card was declined');
    // User stays on checkout page
    await expect(page).toHaveURL(/\/checkout/);
  });
});
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: [['html'], ['github']],

  use: {
    baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
  ],

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Mutation Testing with Stryker

Coverage percentage lies. 80% coverage can still miss critical branches if tests don't assert correctly. Mutation testing inserts bugs ("mutants") into your code and checks if your tests catch them.

npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
// stryker.config.json
{
  "testRunner": "vitest",
  "mutate": ["src/lib/**/*.ts", "src/services/**/*.ts"],
  "reporters": ["html", "clear-text"],
  "thresholds": {
    "high": 80,
    "low": 60,
    "break": 50
  },
  "timeoutMS": 10000,
  "concurrency": 4
}
npx stryker run

A Mutation Score below 60% means your tests aren't catching most bugs β€” even if coverage is 85%. Common survivors (untested mutations):

  • Off-by-one errors in boundary conditions (> vs >=)
  • Wrong comparison operators (=== changed to !==)
  • Missing null checks
  • Incorrect default values

Target 70%+ mutation score for business-critical code.


CI Pipeline Integration

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]

jobs:
  unit-integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        ports: ['5432:5432']
        options: --health-cmd pg_isready --health-interval 5s

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - run: npm ci
      - run: npm run test:coverage
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/test_db
      - uses: codecov/codecov-action@v4

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e
        env:
          E2E_BASE_URL: http://localhost:3000
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Cost and Timeline for Test Infrastructure

Team SizeTest Suite ScopeSetup TimeOngoing Maintenance
1–3 devsUnit + integration1–2 weeks10% of dev time
4–8 devsFull pyramid + CI2–4 weeks15% of dev time
8–15 devsPyramid + mutation + perf4–8 weeks20% of dev time
15+ devsPlatform-level test infra8–16 weeksDedicated QE team

Testing ROI: Teams with mature test suites deploy 2–3Γ— more frequently with 50–70% fewer production incidents (DORA research, 2024).


Working With Viprasol

We build test suites alongside features β€” not as an afterthought. Our engineering team sets up the full testing pyramid from project kickoff, giving your team confidence to ship fast.

What we deliver:

  • Vitest setup with integration tests against real databases
  • Playwright E2E suite covering critical user flows
  • MSW handlers for external API mocking
  • CI/CD pipeline with coverage gates and artifact upload
  • Mutation testing baseline with remediation plan

β†’ Talk to us about code quality β†’ Software development services


Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.