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
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:
| Layer | Count | Speed | Confidence | Cost |
|---|---|---|---|---|
| Unit (pure functions, utils) | Many (200β500+) | <10ms each | Lowβmedium | Low |
| Integration (services + DB) | Medium (50β150) | 50β500ms each | High | Medium |
| E2E (critical user flows) | Few (10β30) | 2β30s each | Very high | High |
| Contract (API boundaries) | Medium (20β60) | 50β200ms each | High | Medium |
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 Size | Test Suite Scope | Setup Time | Ongoing Maintenance |
|---|---|---|---|
| 1β3 devs | Unit + integration | 1β2 weeks | 10% of dev time |
| 4β8 devs | Full pyramid + CI | 2β4 weeks | 15% of dev time |
| 8β15 devs | Pyramid + mutation + perf | 4β8 weeks | 20% of dev time |
| 15+ devs | Platform-level test infra | 8β16 weeks | Dedicated 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
About the Author
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.
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
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.