Who This Guide Is For
This guide is for developers who ship code to production and want to stop relying on manual spot checks to know whether it works. You have a web application — likely built with a framework like Laravel, Django, Rails, or Express — and you want a testing setup that catches regressions before your users do. You understand the basics of your language and framework and want to know how to structure tests, configure test infrastructure, and integrate the whole thing into a CI pipeline so that every push gets validated automatically.
Before You Start
You should have a working application with enough functionality to test. Writing tests for an application that does not exist yet is an exercise in specification, not testing — useful in some contexts, but not what this guide covers. You should also have version control in place (Git, specifically) because the CI integration steps assume your code lives in a repository that triggers pipelines on push.
You need a clear mental model of what your application does. Testing requires you to articulate expected behaviour, which means you need to know what the expected behaviour is. If the business rules are ambiguous or undocumented, testing will force you to clarify them.
Accept that the initial setup takes time. The first fifty tests are harder to write than the next five hundred, because they require you to build the infrastructure — test databases, factories, helpers, base classes — that makes subsequent tests fast to write.
Step 1: Understand the Testing Taxonomy
The terms “unit test,” “feature test,” and “integration test” are used inconsistently across the industry, and debates about their exact definitions waste more time than they save. What matters is understanding the spectrum from isolated to integrated, and choosing the right level for what you are testing.
Unit tests verify a single class or function in isolation. The code under test receives its inputs directly and returns a value or throws an exception. External dependencies — databases, APIs, file systems, queues — are replaced with test doubles (mocks, stubs, or fakes). Unit tests are fast because they do not touch anything outside the process. They are ideal for testing business logic, validation rules, calculations, and data transformations.
A unit test for a pricing calculator, for example, instantiates the calculator, passes it a set of inputs, and asserts that the result matches the expected price. It does not care how the inputs were collected or where the result will be stored.
Feature tests (sometimes called functional tests) exercise a complete request-response cycle through your application. In a web application, a feature test sends an HTTP request to a route and asserts on the response — status code, response body, headers, side effects like database records or dispatched jobs. Feature tests use a real application instance but typically run against a test database rather than production data.
Feature tests are the backbone of most web application test suites because they validate that the pieces work together. A feature test for a user registration endpoint sends a POST request with valid data and asserts that the response is a 201, the user record exists in the database, and a welcome email was dispatched.
Integration tests verify that your application interacts correctly with external systems — a payment gateway, a third-party API, a message queue, or a search engine. These tests are slower and more fragile because they depend on external availability. They are essential for critical integrations but should be a smaller proportion of your test suite.
The practical split for most web applications is roughly 30% unit tests, 60% feature tests, and 10% integration tests. This is not a rigid rule. Applications with complex business logic benefit from more unit tests. Applications that are primarily CRUD with many integrations benefit from more feature tests and integration tests. Test what matters, not what is easy to count.
Step 2: Configure Your Test Database
Feature tests need a database, and that database must not be your development or production database. Test suites create, modify, and delete data aggressively, and you need a database that can be wiped clean between test runs without consequence.
SQLite in-memory is the fastest option. The database exists only in memory for the duration of the test run and is discarded afterwards. Most frameworks support this with minimal configuration. The trade-off is that SQLite has different behaviour from MySQL or PostgreSQL in edge cases — particularly around strict mode, JSON columns, and certain types of constraint. If your application relies on database-specific features, SQLite will hide bugs rather than catch them.
A dedicated test database on the same engine as production is the safer option. If your application runs on PostgreSQL in production, run your tests against a local PostgreSQL instance with a dedicated test database. This ensures your tests encounter the same SQL behaviour as production. The overhead is that you need the database server running locally and the database needs to be migrated before each test run.
Database transactions for test isolation are the standard pattern. Each test runs inside a database transaction that is rolled back after the test completes. This means tests cannot affect each other — test A creates a user, test B runs in a clean database regardless. Most frameworks provide this as a trait or configuration option. The alternative is to truncate all tables between tests, which is slower but necessary when testing code that commits transactions internally.
Configure your test database connection in your framework’s test configuration file, not in your main configuration. You want zero risk of tests running against the wrong database.
Step 3: Build Factories and Seeders
Writing tests becomes painful quickly if every test has to manually construct all its prerequisite data. Factories solve this by providing a concise way to generate test data with sensible defaults.
A factory for a User model defines default values for every required field — name, email, password hash, creation timestamp. When a test needs a user, it calls the factory and gets a fully valid record with a single line of code. When a test needs a user with specific attributes, it overrides just the relevant fields.
Design factories to produce valid records by default. Every field should have a default that passes validation. If a test needs an invalid record to test error handling, it explicitly sets the invalid field.
Use relationships in factories. If a Project belongs to a Client, the project factory should automatically create an associated client when one is not provided. This saves tests from having to build entire object graphs manually.
Factories are not fixtures. Fixtures are static datasets loaded before tests run. They become brittle as the schema evolves and create hidden dependencies between tests. Factories generate fresh data for each test, making each test self-contained.
Seeders serve a different purpose. They populate the database with reference data that the application depends on — permission roles, country lists, configuration records, subscription tiers. Run seeders once during test setup rather than in every test. If your application’s boot process assumes certain records exist, the test database needs those records too.
Step 4: Write Tests That Catch Real Bugs
A test suite full of trivial assertions provides false confidence. Testing that a getter returns the value that was set, or that a route returns 200, does not catch the bugs that actually reach production. Write tests that exercise the behaviour you are afraid of breaking.
Test the happy path first, then the edge cases. For a payment endpoint, the happy path is: valid card, sufficient funds, correct amount charged, receipt generated, database updated. The edge cases are: declined card, duplicate submission, amount mismatch, network timeout to the payment gateway, partial failure where the charge succeeds but the receipt fails. The edge cases are where the real bugs live.
Test side effects, not just return values. A registration endpoint might return a 201, but the test should also assert that the user record was created in the database, the welcome email was dispatched, the analytics event was fired, and the audit log entry was written. If any of those side effects breaks, the user has registered but something downstream is wrong.
Test authorization and access control explicitly. For every endpoint that requires authentication or specific permissions, write a test that attempts access without authentication, with authentication but without the required permission, and with full authorization. Authorization bugs are among the most consequential and the most likely to be introduced during refactoring.
Use assertions that communicate intent. Assert the specific thing you care about, not a proxy for it. If you care that the response contains the user’s name, assert on the name field in the response body, not on the string length of the response. Specific assertions produce meaningful failure messages. Generic assertions produce confusion.
Keep tests independent. Each test should set up its own data, perform its own action, and make its own assertions. Tests that depend on the execution order of other tests or on data created by previous tests will break intermittently and erode trust in the suite. The database transaction rollback from Step 2 enforces data independence. Enforce logical independence by never sharing state between test methods.
Step 5: Structure the Test Suite for Maintainability
A test suite with three thousand tests in a flat directory is hard to navigate and hard to run selectively. Structure matters.
Mirror the application structure. If your application has controllers, services, and models, your test directory should have corresponding subdirectories. A test for UserController lives in tests/Feature/Controllers/UserControllerTest. A test for the PricingService lives in tests/Unit/Services/PricingServiceTest. This convention makes it obvious where to find the test for any given class and where to add a new test.
Use test suites to group by type. Configure your test runner with named suites — Unit, Feature, Integration — so you can run subsets quickly. During development, you run the unit tests frequently (they complete in seconds) and the full suite before pushing. In CI, you run everything.
Name tests descriptively. A test named testUserCreation tells you nothing when it fails. A test named test_registration_with_duplicate_email_returns_422_with_validation_error tells you exactly what broke and what the expected behaviour was. Verbose test names are documentation.
Extract shared setup into base classes or traits. If every test in a controller suite needs an authenticated user, create a trait that handles setup. But be careful not to hide too much — a developer reading a failing test should be able to understand the setup without tracing through five levels of inheritance.
Step 6: Integrate Tests Into Your CI Pipeline
Tests that run only on developer machines are tests that get skipped. CI integration makes the test suite a gate that every change must pass through.
Run tests on every push to every branch. Not just the main branch. A test failure on a feature branch is cheap to fix. A test failure discovered after merging to main disrupts the entire team. Configure your pipeline to trigger on all pushes and all pull requests.
Run the test suite in stages. Unit tests first (fast, cheap), then feature tests (slower, need database), then integration tests (slowest, may need external services). If unit tests fail, skip the rest — there is no point running feature tests against code that has unit-level bugs.
Configure the CI environment to match production. Use the same database engine, the same language version, and the same dependency versions as production. If your CI runs tests against SQLite but production uses PostgreSQL, you will encounter database-specific bugs only in production. Container-based CI services make environment parity straightforward — define your test environment in a Dockerfile or a pipeline configuration and it runs identically every time.
Set up test databases in CI. Most CI platforms provide service containers — a MySQL or PostgreSQL instance that starts alongside your test runner. Configure your test database connection to use the service container. Run migrations before the test suite starts. The database is ephemeral — it exists only for the duration of the pipeline run.
Treat test failures as blockers. If a test fails, the pipeline fails, and the code does not merge. No exceptions, no manual overrides, no “we will fix it later.” The moment you start allowing test failures through, the suite’s value collapses because nobody trusts it.
Track test suite performance. If your test suite takes forty-five minutes, developers will avoid running it locally and the feedback loop becomes too slow. Monitor the duration and address slow tests proactively. Parallelise where possible — most CI platforms support splitting the suite across multiple runners.
Common Mistakes
- Writing tests after the fact for code that already works. Retroactive tests tend to test the implementation rather than the behaviour. Prioritise tests for new code and for code you are about to change.
- Mocking too aggressively. A test that mocks every dependency tests nothing but the wiring between mocks. Mock external boundaries (APIs, file systems, queues) and test internal logic with real objects.
- Testing framework internals. You do not need to test that your ORM saves a record to the database. The ORM authors already tested that. Test your application’s behaviour, not the tools it uses.
- Ignoring flaky tests. A test that fails intermittently trains the team to ignore test failures. Fix flaky tests immediately or delete them. A smaller, reliable suite is worth more than a larger, unreliable one.
- No test for the bug. When you fix a bug, write a test that reproduces the bug first, then fix it. The test proves the fix works and prevents the bug from returning. A bug without a test will recur.
What Good Looks Like
A mature test suite runs in under five minutes for unit tests and under fifteen minutes for the full suite. It catches real bugs before they reach production. Developers trust it enough to refactor with confidence. The CI pipeline enforces it on every push. Test failures are treated as defects, not inconveniences. New features ship with tests, and the test count grows proportionally with the codebase.
Next Steps
For the CI pipeline that runs your tests automatically, How to Set Up CI/CD for a Laravel Project covers pipeline configuration and deployment. For structuring the API endpoints your feature tests will exercise, see How to Structure a REST API. If your application needs a staging environment to run integration tests against, How to Set Up a Staging Environment covers the setup.