In 30 seconds
Tests are small programs that check your code does what it should. Unit tests check one function, feature or integration tests check a whole request against a real database, and end-to-end tests click through the real app in a browser. A good suite runs on every pull request and catches regressions before customers do.
Why I use it
- I write tests because the parts that move money, stock and loyalty points must never silently break.
- I rely on feature tests for APIs because they test what clients actually call, not internal details.
- I run tests in CI on every pull request so the team, and AI agents working with us, get fast feedback.
- Tests are what make refactoring a large codebase possible instead of terrifying.
How it works
01The test pyramid
Think of checking a car. You test each part on a bench (unit tests): many, fast, cheap. You test systems together, the engine with the gearbox (integration and feature tests): fewer, slower. Then you take the whole car for a drive (end-to-end tests): few, slowest, but closest to reality. The pyramid says: many unit tests at the bottom, a solid layer of integration tests, a thin layer of E2E on top. For backend APIs many teams prefer a 'trophy' shape, with the biggest layer being feature tests that call real endpoints with a real test database, because that is where most bugs live. Either way: fast tests run constantly, slow tests cover only critical journeys.
- Unit: one function or class, no database or network.
- Feature / integration: HTTP request in, database and response checked.
- E2E: a real browser or device going through the real UI and API.
02PHPUnit and Pest for Laravel
Laravel ships with PHPUnit and Pest (a friendlier syntax on top of PHPUnit). Feature tests send HTTP requests to your app in memory and assert on the response, the database and side effects. Use the RefreshDatabase trait so each test starts clean; run tests against the same database engine as production (MySQL or PostgreSQL), because SQLite behaves differently in edge cases. Factories create realistic models in one line, with states for variations like a paid order or a blocked user. Laravel's fakes replace external effects: Queue::fake, Mail::fake, Notification::fake, Event::fake and Http::fake let you assert something was sent without sending it.
it('lets a customer place an order', function () {
Queue::fake();
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 2500]);
$this->actingAs($user)
->postJson('/api/v1/orders', ['items' => [['product_id' => $product->id, 'qty' => 2]]])
->assertCreated()
->assertJsonPath('data.total', 5000);
$this->assertDatabaseHas('orders', ['user_id' => $user->id, 'total' => 5000]);
Queue::assertPushed(SendOrderConfirmation::class);
});
03Jest, Vitest and node:test for TypeScript
In the JavaScript world, Vitest is the modern default for Vite-based and most new TypeScript projects: fast, ESM-native and Jest-compatible. Jest is still common, especially in React Native and older codebases. Node also has a built-in runner, node:test, with no dependencies at all. For NestJS, use the testing module to build a slice of the app and override providers with fakes; for API tests, supertest sends requests to the app. Test pure logic (pricing, permissions, date rules) with many small unit tests, and test endpoints against a real PostgreSQL in Docker or Testcontainers rather than mocking the ORM.
import { describe, it, expect } from 'vitest';
import { applyPromotion } from './pricing';
describe('applyPromotion', () => {
it('never discounts below zero', () => {
expect(applyPromotion({ subtotal: 1000 }, { type: 'fixed', amount: 1500 }).total).toBe(0);
});
it('applies percentage to subtotal only, not delivery', () => {
const r = applyPromotion({ subtotal: 10_000, delivery: 500 }, { type: 'percent', amount: 10 });
expect(r.total).toBe(9_500);
});
});
04Playwright for end-to-end tests
Playwright drives real Chromium, Firefox and WebKit browsers. It auto-waits for elements, so tests are far less flaky than older tools, and it records traces with screenshots and network logs when something fails. Write E2E tests for a handful of journeys that make or lose money: sign-in, add to cart and checkout, a core admin action. Select elements by role and visible text, the way users find them, not by CSS classes. Seed the data each test needs through the API or a fixture rather than clicking through setup screens, and run the suite against a staging-like environment with external services stubbed.
import { test, expect } from '@playwright/test';
test('guest can check out', async ({ page }) => {
await page.goto('/products/sample-product');
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByLabel('Phone').fill('+10000000000');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('heading', { name: 'Thank you' })).toBeVisible();
});
05Mocking external services and fixtures
Your tests should never call a real payment gateway, SMS provider or ERP. Replace them at the boundary: wrap each integration in your own client class, and in tests swap it for a fake or stub its HTTP calls (Http::fake in Laravel, MSW or nock in Node). Record realistic responses, including errors and timeouts, as fixtures so you test failure paths too. Do not mock what you own, like your own services or the database; mocking too much produces tests that pass while production breaks. Factories build test data with sensible defaults, so each test states only what matters to it. Keep tests independent: no shared state, no required order.
06Testing in CI and what to test first
Run lint, type checks and tests on every pull request, and block merging when they fail. Use a real database service in the CI job, run tests in parallel, and cache dependencies so feedback arrives in minutes. Run E2E on main or nightly if it is slow. In a real product with no tests, do not aim for coverage numbers. Start where a bug costs most: money (checkout totals, discounts, refunds, payment callbacks), permissions and tenant isolation, and state machines like order status. Then add a test for every bug you fix, so it never returns. Coverage grows naturally where it matters.
# .github/workflows/test.yml (excerpt)
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env: { POSTGRES_PASSWORD: postgres, POSTGRES_DB: app_test }
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 24, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck && pnpm lint && pnpm test
Use it when
- Any code that calculates money, stock, points or permissions.
- Before refactoring, to lock in current behaviour.
- For every bug fix, as a regression test.
- Shared packages and APIs used by several apps.
Skip it when
- Throwaway prototypes whose code will be deleted next week.
- Testing framework internals or trivial getters just to raise coverage.
- Huge snapshot tests nobody reads, which get updated blindly.
Alternatives
- Static typing and linting
TypeScript strict and PHPStan catch a class of bugs before any test runs.
- Contract tests
Check that API producers and consumers agree on shapes, without full E2E.
- Manual QA and exploratory testing
Still valuable for UX and edge cases automation misses.
- Monitoring in production
Error tracking and alerts catch what tests could not foresee.
Best practices
- Test behaviour through public interfaces, not private implementation details.
- Prefer feature tests with a real database for APIs.
- Fake external services at the boundary; do not mock your own code.
- Use factories so each test shows only the data that matters.
- Write a failing test before fixing each bug.
- Run tests on every pull request and keep the suite fast.
- Keep E2E tests few, focused on revenue-critical journeys.
Common mistakes
- Chasing 100% coverage while the checkout flow has no tests.
- Mocking the database or ORM so tests pass but real queries fail.
- Flaky tests that depend on time, order or network, and teams learning to ignore red builds.
- Testing on SQLite while production runs MySQL or PostgreSQL.
- Real API keys or calls to live payment providers in tests.
Deep dive
07Fast, isolated database tests
Database tests are the most valuable backend tests and the easiest to make slow. Isolation strategies, from fastest: wrap each test in a transaction and roll it back (Laravel's RefreshDatabase does this after migrating once per run, and LazilyRefreshDatabase skips migration when a test never touches the database); truncate tables between tests when code under test opens its own transactions or connections; or recreate the database, which is slowest. Run in parallel: php artisan test --parallel gives each process its own database with a suffix, so tests do not collide. In Node, Testcontainers starts a real PostgreSQL in Docker per test run, so CI and laptops use the same engine and version as production. Speed tricks: keep the database on tmpfs in CI, migrate once and clone from a template database in PostgreSQL, and never sleep in tests.
import { before, after } from 'node:test';
import { execSync } from 'node:child_process';
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';
let pg: StartedPostgreSqlContainer;
before(async () => {
pg = await new PostgreSqlContainer('postgres:17').start();
process.env.DATABASE_URL = pg.getConnectionUri();
execSync('npx prisma migrate deploy', { stdio: 'inherit' });
});
after(async () => {
await pg.stop();
});
08Beyond examples: property-based and mutation testing
Example tests check the cases you thought of. Property-based testing checks rules against hundreds of generated inputs: instead of 'a 10% discount on 1,000 gives 900', you state 'for any subtotal and any percentage from 0 to 100, the total is never negative and never above the subtotal'. Libraries such as fast-check for TypeScript generate inputs and, when a case fails, shrink it to the smallest failing example. It shines for pricing, rounding, parsing and date logic. Mutation testing measures how good your tests are rather than how much code they run. Tools such as Infection for PHP and Stryker for JavaScript make small changes to your code, for example turning >= into >, and rerun the tests. If the tests still pass, that mutant survived, which shows an assertion is missing. Coverage says a line ran; mutation score says a test would notice if it were wrong.
import fc from 'fast-check';
import { test, expect } from 'vitest';
import { applyPromotion } from './pricing';
test('percentage promotions stay within 0 and the subtotal', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 10_000_000 }),
fc.integer({ min: 0, max: 100 }),
(subtotal, percent) => {
const { total } = applyPromotion({ subtotal }, { type: 'percent', amount: percent });
expect(total).toBeGreaterThanOrEqual(0);
expect(total).toBeLessThanOrEqual(subtotal);
},
),
);
});
09Architecture and contract tests
Some rules are about structure, not behaviour, and tests can guard them too. Pest's architecture testing lets you assert that controllers never use the DB facade, that the domain layer never imports HTTP classes, or that no debug functions such as dd() reach production; it also ships presets for common rule sets. Between apps and services, contract tests catch the most expensive class of bug: one side changes a field the other depends on. With consumer-driven contracts (Pact is the best-known tool), the consumer records what it expects from the provider, and the provider's CI verifies it still satisfies every consumer. A lighter option is validating real API responses in feature tests against your OpenAPI schema. Both are much faster and more precise than full end-to-end suites for proving that two systems still agree.
// tests/ArchTest.php (Pest)
arch()->preset()->php(); // e.g. no dd(), dump() or var_dump()
arch()->preset()->security(); // flags eval(), md5(), sha1() and similar
arch('controllers do not query the database directly')
->expect('App\Http\Controllers')
->not->toUse('Illuminate\Support\Facades\DB');
arch('domain does not depend on HTTP')
->expect('App\Domain')
->not->toUse('Illuminate\Http');
10Testing time, retries and concurrency
The bugs that reach production are often about time and repetition: a subscription that expires one second early, a job that double-sends when retried, two requests that oversell stock. Test them on purpose. Freeze or move time instead of waiting: Laravel's travelTo() and travel(), Carbon::setTestNow(), or vi.useFakeTimers() and vi.setSystemTime() in Vitest. Test boundaries, the second before and after a deadline, and time zones around midnight and daylight saving changes. For idempotency, run the same job or webhook twice and assert one effect. For race conditions, a unit test with mocks proves nothing; fire real concurrent requests against a real database, for example with Promise.all in an integration test or a short k6 script, and assert that the invariant, such as stock never below zero, still holds.
it('expires a subscription exactly at its end time', function () {
$sub = Subscription::factory()->create(['ends_at' => '2026-03-01 00:00:00']);
$this->travelTo('2026-02-28 23:59:59');
$this->artisan('subscriptions:expire')->assertSuccessful();
expect($sub->fresh()->status)->toBe(SubscriptionStatus::Active);
$this->travelTo('2026-03-01 00:00:01');
$this->artisan('subscriptions:expire')->assertSuccessful();
expect($sub->fresh()->status)->toBe(SubscriptionStatus::Expired);
});
it('creates one invoice even if the job runs twice', function () {
$order = Order::factory()->paid()->create();
GenerateInvoice::dispatchSync($order->id);
GenerateInvoice::dispatchSync($order->id);
expect(Invoice::where('order_id', $order->id)->count())->toBe(1);
});
Real-world scenarios
The CI pipeline takes 35 minutes
Problem
The backend suite has grown to a few thousand tests. Pull requests wait over half an hour for CI, so developers batch changes and merge less often.
How to solve it
Profile first: Pest and PHPUnit can report the slowest tests, and a few usually dominate. Remove sleeps and real network calls, and add Http::preventStrayRequests() so any unfaked HTTP call fails loudly. Switch to transaction-based refresh and lazy refresh, run with --parallel, and put the CI database on tmpfs. Split the pipeline into parallel jobs for static analysis, unit and feature tests, and shard large suites across machines with Pest or Playwright sharding. Cache Composer and pnpm dependencies. Move full E2E runs to the merge queue or nightly, keeping a small smoke set on every pull request. Aim for under ten minutes.
85% coverage and still a pricing bug in production
Problem
A discount combination produced negative totals for a weekend. The pricing module had high coverage, and every test was green.
How to solve it
Coverage proved the code ran, not that results were checked. Write a regression test for the exact combination first. Then add property-based tests for pricing invariants: totals never negative, never above the subtotal, and discounts never stacking beyond the rules. Run mutation testing with Infection or Stryker on the pricing module and fix surviving mutants by adding real assertions, often on boundary values such as zero, exact thresholds and 100%. Make pricing a pure function over value objects so these tests stay fast. Finally, add a guard in code that rejects a negative total and alerts, because tests reduce risk but never remove it.
The mobile app broke after a 'harmless' API change
Problem
A backend developer renamed a field and made another one nullable. All backend tests passed, but the mobile app crashed on the order screen for every user.
How to solve it
Nothing checked the contract between the two apps. Short term, restore the old field next to the new one and release a fix. Long term, make the contract executable: generate an OpenAPI document from the code or maintain it by hand, and validate real responses in feature tests against it, so a renamed or newly nullable field fails CI. For apps you also own, consider consumer-driven contract tests with Pact, so the mobile team's expectations are verified on every backend build. Add a rule in code review that response changes are additive only, with removals going through a versioned deprecation.
Cheat sheet
- Run Laravel tests in parallel
php artisan test --parallel- Run one Pest test by name
./vendor/bin/pest --filter="places an order"- Fail CI below a coverage threshold
./vendor/bin/pest --coverage --min=80- Block any unfaked HTTP call in tests
Http::preventStrayRequests();- Vitest once with coverage
npx vitest run --coverage- Split Playwright across four CI machines
npx playwright test --shard=1/4- Open a Playwright failure trace
npx playwright show-trace trace.zip- Record a Playwright test by clicking
npx playwright codegen https://staging.example.com- Mutation testing for PHP
vendor/bin/infection --min-msi=70 --threads=4- Mutation testing for JS and TS
npx stryker run
In my real projects
- A pet-supplies platform I lead has 95 backend test files covering its Laravel services and endpoints.
- A gym-chain CRM I built almost solo has 31 test files.
- A multi-tenant POS SaaS I co-lead has about 270 test files on node:test, with a 'wrong tenant' case in every module test.
- In a coffee app, a post-edit hook runs type checks, lint and locale parity against a recorded baseline, and I use a browser-verify step at 390, 768 and 1920 widths for UI work.
Questions & answers
Q1What is the difference between unit, integration and end-to-end tests?
A unit test checks one small piece, a function or class, in isolation, with no database or network; it runs in milliseconds. An integration or feature test checks several parts together, for example an HTTP request through routing, validation, services and a real test database, asserting on the response and stored data. An end-to-end test runs the real application as a user would, in a browser or on a device, across frontend and backend. Moving up, tests become more realistic but slower and more brittle, so you want many unit tests, a solid integration layer and few E2E tests.
Q2What would you test first in a product with no tests?
Whatever costs the most when it breaks. In commerce that is money: cart totals, discounts and promotion stacking, taxes, refunds and payment callbacks. Next, security: authentication, permissions and tenant isolation, including a test that one tenant cannot see another's data. Then state machines such as order status transitions and subscription expiry. I write feature tests at the API level for these, because they give the most protection per test and survive refactoring. From then on every bug fix comes with a regression test. Coverage grows where risk is, not evenly.
Q3When should you mock, and when not?
Mock things you do not own that are slow, costly or unpredictable: payment gateways, SMS and email providers, ERPs, the clock, randomness. Wrap each in your own adapter and fake it at that boundary, including error and timeout responses. Do not mock things you own, like your services or the database: a test that mocks the ORM proves only that you called a method, not that the query works. For the database use a real engine in Docker with transactions or refresh between tests. Over-mocking produces green tests and broken production.
Q4How do you deal with flaky tests?
Treat them as bugs, because a team that learns to ignore red builds stops trusting the suite. Common causes are shared state between tests, dependence on run order, real time and time zones, randomness, network calls and in E2E, waiting on timeouts instead of conditions. Fixes: isolate data per test, freeze time, seed randomness, fake external calls and use Playwright's auto-waiting assertions. Quarantine a flaky test briefly with an owner and a deadline rather than letting it fail randomly. Playwright traces and CI artifacts usually show the cause quickly.
Q5Pest or PHPUnit, Jest or Vitest?
Pest runs on PHPUnit, so it is a matter of syntax: Pest is more concise and readable and is a common default in new Laravel projects, while PHPUnit classes are fine and fully supported. Mixing both in one project works. For TypeScript, Vitest is my default for new projects: fast, ESM-native, works with Vite and offers a Jest-compatible API. Jest remains common in React Native and older codebases, so there is no reason to migrate a working suite. For Node backends, the built-in node:test runner is a solid zero-dependency option. The tool matters less than writing meaningful tests.
Senior-level questions
S1How do you keep a large test suite fast as the codebase grows?
Treat suite speed as a product metric with a budget, say under ten minutes in CI. Structurally, keep most logic in pure functions tested by fast unit tests, and use feature tests with a real database for endpoints, isolated by transactions rather than re-migrating. Run in parallel locally and shard across machines in CI, with dependency caching and a tmpfs database. Remove slow patterns: sleeps, real network calls, huge factories that build entire object graphs when two models are enough. Track the slowest tests each week and fix the top offenders. Run only affected tests on pull requests in a monorepo, but always the full suite before merge. The trade-off is investment in test infrastructure, which pays back in how often people run the tests.
S2What testing strategy do you use for a system with several apps and services?
Each component tests itself thoroughly in isolation, and the seams get dedicated, cheap checks. Inside a service: unit tests for domain logic and feature tests against a real database, with external systems faked at the adapter. Between components: contract tests, either consumer-driven with Pact or schema validation against OpenAPI, so producers learn about breaking changes in their own CI. Shared packages have their own tests and semantic versions or monorepo-affected runs. Then a thin end-to-end layer covers a handful of revenue-critical journeys in a staging environment. Full end-to-end suites across many services are slow and flaky, so they cannot be the main safety net. Production monitoring, synthetic checks and feature flags close the remaining gap.
S3Is a coverage target a good idea?
As a floor that prevents decay, yes; as a goal, no. A team told to hit 90% writes tests for getters and framework glue, and sometimes tests without assertions, because that is the cheapest way to move the number. Meanwhile the risky paths, refunds, concurrency, permissions, may remain weakly asserted. I prefer risk-based targets: high expectations for pricing, payments, auth and tenant isolation, lighter ones for admin screens. I use coverage reports to find untested critical code, not to grade people, and mutation testing on core modules to check that tests actually assert results. A reasonable global threshold that blocks big drops, plus 'every bug fix comes with a test', produces better suites than any single percentage.
S4Tests or monitoring: where do you invest when time is limited?
Both, aimed at different failures. Tests catch what you can foresee before users see it: logic errors, regressions, broken contracts. Monitoring catches what you could not foresee: real data shapes, third-party outages, performance under real load, configuration mistakes. With limited time I write feature tests for money, auth and core state transitions, because those failures are expensive and predictable. Then I add error tracking with alerts, health checks, structured logs with request ids and a few business metrics, such as orders per hour, which detect silent failures no test anticipated. Feature flags and quick rollbacks reduce the cost of what slips through. The trade-off is explicit: tests buy prevention, observability buys fast detection and recovery, and a product needs a minimum of both.
Glossary
- Test double
- Any stand-in for a real dependency in a test: dummy, stub, fake, spy or mock.
- Fake
- A working but simplified implementation, such as an in-memory payment gateway, used instead of the real one.
- Stub
- A double that returns canned answers to calls, without checking how it was used.
- Mock
- A double that verifies it was called in an expected way, such as once with certain arguments.
- Fixture
- Prepared data or files a test relies on, such as a recorded API response or a seeded database.
- Flaky test
- A test that sometimes passes and sometimes fails without any code change.
- Property-based testing
- Checking that a rule holds for many generated inputs instead of a few hand-written examples.
- Mutation score
- The share of deliberately introduced bugs that the test suite detects; a measure of test strength.
- Contract test
- A test that checks a provider and its consumers still agree on request and response shapes.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.