Codeception: Full-Stack Testing Framework for PHP

Codeception: Full-Stack Testing Framework for PHP

Most PHP projects end up with three separate testing tools: PHPUnit for unit tests, something else for HTTP integration, and a browser automation library bolted on for end-to-end scenarios. Each tool has its own configuration format, its own runner, and its own reporting conventions. Codeception was built to fix exactly that fragmentation — one framework, one runner, one report for all three layers of your test suite.

What Codeception Is

Codeception is a full-stack PHP testing framework that handles unit tests, functional tests (HTTP-level), and acceptance tests (browser automation) under a single CLI and configuration model. It was created by Michael Bodnarchuk in 2011 and has grown into one of the most complete testing solutions in the PHP ecosystem, with first-class support for Laravel, Symfony, Yii, and any framework-agnostic application.

The three-layer model maps directly to how most applications are tested in practice:

  • Unit tests — test individual classes and methods in isolation, with no external dependencies
  • Functional tests — test complete HTTP request/response cycles without spinning up a real browser, using your framework's kernel directly or a headless HTTP client
  • Acceptance tests — drive a real browser (Chrome, Firefox) via WebDriver to test the application exactly as a user would see it

This hierarchy matters because each layer has a different cost/benefit tradeoff. Unit tests are fast and precise but miss integration problems. Functional tests catch routing, middleware, and controller bugs cheaply. Acceptance tests catch UI and JavaScript problems but are slow. Codeception lets you write all three in the same project with the same patterns.

Installation and Project Setup

Add Codeception to any PHP project via Composer:

composer require codeception/codeception --dev

Then run the bootstrap command to generate the default suite structure:

php vendor/bin/codecept bootstrap

This creates:

tests/
  _support/
    AcceptanceTester.php
    FunctionalTester.php
    UnitTester.php
    Helper/
      Acceptance.php
      Functional.php
      Unit.php
  Acceptance/
  Functional/
  Unit/
codeception.yml

For Laravel projects, install the framework module:

composer require codeception/module-laravel --dev

For Symfony:

composer require codeception/module-symfony --dev

Suite Configuration

The root codeception.yml file defines global settings. Each suite gets its own YAML file inside tests/:

# codeception.yml
paths:
  tests: tests
  output: tests/_output
  data: tests/_data
  support: tests/_support
  envs: tests/_envs
actor_suffix: Tester
extensions:
  enabled:
    - Codeception\Extension\RunFailed
# tests/Unit.suite.yml
actor: UnitTester
modules:
  enabled:
    - Asserts
# tests/Functional.suite.yml
actor: FunctionalTester
modules:
  enabled:
    - Laravel:
        environment_file: .env.testing
    - Asserts
# tests/Acceptance.suite.yml
actor: AcceptanceTester
modules:
  enabled:
    - WebDriver:
        url: http://localhost:8000
        browser: chrome
        window_size: 1920x1080
        capabilities:
          chromeOptions:
            args: ["--headless", "--disable-gpu", "--no-sandbox"]
    - Asserts

Unit Tests with Codeception

Unit tests in Codeception extend \Codeception\Test\Unit and use the $this->tester actor for assertions that complement PHPUnit's native assertion methods:

<?php

namespace Tests\Unit;

use App\Services\OrderCalculator;
use Codeception\Test\Unit;

class OrderCalculatorTest extends Unit
{
    protected UnitTester $tester;

    private OrderCalculator $calculator;

    protected function setUp(): void
    {
        parent::setUp();
        $this->calculator = new OrderCalculator(taxRate: 0.20);
    }

    public function testSubtotalIsCalculatedCorrectly(): void
    {
        $items = [
            ['price' => 1000, 'quantity' => 2],
            ['price' => 500,  'quantity' => 1],
        ];

        $result = $this->calculator->subtotal($items);

        $this->assertEquals(2500, $result);
    }

    public function testTaxIsAppliedToSubtotal(): void
    {
        $items = [['price' => 1000, 'quantity' => 1]];

        $result = $this->calculator->total($items);

        $this->assertEquals(1200, $result);
    }

    public function testEmptyCartReturnsZero(): void
    {
        $this->assertEquals(0, $this->calculator->total([]));
    }

    public function testNegativePriceThrowsException(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->calculator->total([['price' => -100, 'quantity' => 1]]);
    }
}

The $this->tester actor exposes helper methods from enabled modules. With the Asserts module, you get readable aliases:

$this->tester->assertEquals(42, $result);
$this->tester->assertContains('admin', $roles);
$this->tester->assertInstanceOf(Collection::class, $items);

Functional Tests

Functional tests exercise your application's HTTP layer without a real browser. For Laravel, the Laravel module bootstraps the framework kernel and lets you make requests directly against it — no network round-trips, dramatically faster than WebDriver:

<?php

namespace Tests\Functional;

class ProductControllerTest extends \Codeception\Test\Unit
{
    protected FunctionalTester $tester;

    public function testProductListIsReturned(): void
    {
        $this->tester->amOnPage('/products');
        $this->tester->seeResponseCodeIs(200);
        $this->tester->see('Available Products');
    }

    public function testUnauthenticatedUserIsRedirectedFromDashboard(): void
    {
        $this->tester->amOnPage('/dashboard');
        $this->tester->seeCurrentUrlEquals('/login');
    }

    public function testUserCanCreateProduct(): void
    {
        $user = User::factory()->create();
        $this->tester->amLoggedAs($user);

        $this->tester->sendPost('/products', [
            '_token' => csrf_token(),
            'name'   => 'Test Widget',
            'price'  => 999,
            'stock'  => 100,
        ]);

        $this->tester->seeResponseCodeIs(302);
        $this->tester->seeInDatabase('products', ['name' => 'Test Widget']);
    }

    public function testValidationErrorsAreReturned(): void
    {
        $user = User::factory()->create();
        $this->tester->amLoggedAs($user);

        $this->tester->sendPost('/products', ['name' => '']);

        $this->tester->seeSessionHasErrors(['name']);
    }
}

For framework-agnostic apps or Symfony projects, the PHPBrowser module makes real HTTP requests using Guzzle without requiring a running server on a separate port:

# tests/Functional.suite.yml (non-Laravel)
modules:
  enabled:
    - PhpBrowser:
        url: http://localhost:8080

Acceptance Tests with ChromeDriver

Acceptance tests drive a real browser. Configure the WebDriver module to connect to a local ChromeDriver or Selenium Grid:

<?php

namespace Tests\Acceptance;

class CheckoutTest extends \Codeception\Test\Unit
{
    protected AcceptanceTester $tester;

    public function testGuestCannotCheckout(): void
    {
        $this->tester->amOnPage('/cart');
        $this->tester->click('Proceed to Checkout');
        $this->tester->seeCurrentUrlContains('/login');
    }

    public function testRegisteredUserCompletesCheckout(): void
    {
        // Log in via form
        $this->tester->amOnPage('/login');
        $this->tester->fillField('email', 'buyer@example.com');
        $this->tester->fillField('password', 'secret123');
        $this->tester->click('Log In');
        $this->tester->see('Dashboard');

        // Add item to cart
        $this->tester->amOnPage('/products/1');
        $this->tester->click('Add to Cart');
        $this->tester->see('1 item in cart');

        // Checkout
        $this->tester->amOnPage('/cart');
        $this->tester->click('Proceed to Checkout');
        $this->tester->fillField('card_number', '4242424242424242');
        $this->tester->fillField('expiry', '12/28');
        $this->tester->fillField('cvv', '123');
        $this->tester->click('Place Order');

        $this->tester->see('Order Confirmed');
        $this->tester->seeCurrentUrlContains('/orders/');
    }

    public function testSearchReturnsRelevantResults(): void
    {
        $this->tester->amOnPage('/');
        $this->tester->fillField('q', 'widget');
        $this->tester->pressKey('q', \Facebook\WebDriver\WebDriverKeys::ENTER);
        $this->tester->see('Search results for "widget"');
        $this->tester->seeNumberOfElements('.product-card', [1, 50]);
    }
}

Key WebDriver actions:

Method Purpose
amOnPage($url) Navigate to URL
fillField($selector, $value) Type into an input
click($linkOrButton) Click an element
see($text) Assert text is visible
dontSee($text) Assert text is absent
seeElement($selector) Assert element exists
waitForElement($selector) Wait for async content
grabTextFrom($selector) Read element text
seeCurrentUrlEquals($url) Assert current URL

REST API Testing

The REST module makes Codeception an excellent tool for API test suites:

# tests/Api.suite.yml
actor: ApiTester
modules:
  enabled:
    - REST:
        url: http://localhost:8000/api
        depends: Laravel
    - Asserts
<?php

namespace Tests\Api;

class ProductApiTest extends \Codeception\Test\Unit
{
    protected ApiTester $tester;

    public function testListProductsRequiresAuth(): void
    {
        $this->tester->sendGet('/products');
        $this->tester->seeResponseCodeIs(401);
    }

    public function testAuthenticatedUserCanListProducts(): void
    {
        $token = $this->getAuthToken();
        $this->tester->haveHttpHeader('Authorization', "Bearer $token");

        $this->tester->sendGet('/products');

        $this->tester->seeResponseCodeIs(200);
        $this->tester->seeResponseIsJson();
        $this->tester->seeResponseJsonMatchesJsonPath('$.data[*].id');
        $this->tester->seeResponseJsonMatchesJsonPath('$.meta.total');
    }

    public function testCreateProductValidatesRequiredFields(): void
    {
        $token = $this->getAuthToken();
        $this->tester->haveHttpHeader('Authorization', "Bearer $token");
        $this->tester->haveHttpHeader('Content-Type', 'application/json');

        $this->tester->sendPost('/products', []);

        $this->tester->seeResponseCodeIs(422);
        $this->tester->seeResponseContainsJson([
            'errors' => ['name' => [], 'price' => []],
        ]);
    }

    public function testCreateAndRetrieveProduct(): void
    {
        $token = $this->getAuthToken();
        $this->tester->haveHttpHeader('Authorization', "Bearer $token");
        $this->tester->haveHttpHeader('Content-Type', 'application/json');

        $this->tester->sendPost('/products', [
            'name'  => 'API Widget',
            'price' => 1999,
            'stock' => 50,
        ]);

        $this->tester->seeResponseCodeIs(201);
        $id = $this->tester->grabDataFromResponseByJsonPath('$.data.id')[0];

        $this->tester->sendGet("/products/$id");
        $this->tester->seeResponseCodeIs(200);
        $this->tester->seeResponseContainsJson(['data' => ['name' => 'API Widget']]);
    }

    private function getAuthToken(): string
    {
        $this->tester->sendPost('/auth/token', [
            'email'    => 'api@example.com',
            'password' => 'secret',
        ]);
        return $this->tester->grabDataFromResponseByJsonPath('$.token')[0];
    }
}

grabDataFromResponseByJsonPath lets you pull values from JSON responses for use in subsequent requests — essential for chained API tests.

Database Testing

The Db module gives you direct database access in tests. Configure it with your test database DSN:

modules:
  enabled:
    - Db:
        dsn: 'mysql:host=127.0.0.1;dbname=myapp_test'
        user: root
        password: ''
        dump: tests/_data/dump.sql
        cleanup: true   # restore dump before each test
        populate: true  # load dump on suite start

In tests:

// Insert a row and get the inserted ID
$productId = $this->tester->haveInDatabase('products', [
    'name'       => 'Test Widget',
    'price'      => 999,
    'created_at' => date('Y-m-d H:i:s'),
]);

// Assert a row exists
$this->tester->seeInDatabase('products', ['name' => 'Test Widget', 'price' => 999]);

// Assert a row does not exist
$this->tester->dontSeeInDatabase('products', ['name' => 'Deleted Item']);

// Count rows
$this->tester->seeNumRecords(3, 'products', ['status' => 'active']);

For Laravel projects, the Laravel module integrates directly with Eloquent factories:

// Use Eloquent factories via the module
$this->tester->have(User::class, ['role' => 'admin']);
$this->tester->have(Product::class, ['status' => 'published'], 10);

Data Factories and Fixtures

For complex seeding scenarios, Codeception integrates with the DataFactory module (backed by league/factory-muffin):

// tests/_support/Helper/Factory.php
use League\FactoryMuffin\Faker\Facade as Faker;

$fm->define(User::class)->setDefinitions([
    'name'  => Faker::name(),
    'email' => Faker::email(),
    'role'  => 'user',
]);

$fm->define(Product::class)->setDefinitions([
    'name'   => Faker::word(),
    'price'  => Faker::numberBetween(100, 10000),
    'status' => 'published',
]);
// In a test
$user    = $this->tester->have(User::class, ['role' => 'admin']);
$product = $this->tester->have(Product::class, ['owner_id' => $user->id]);

Static SQL fixtures work for simpler cases — place dump.sql in tests/_data/ and the Db module loads it before each suite run.

Running Tests

# Run all suites
php vendor/bin/codecept run

<span class="hljs-comment"># Run a single suite
php vendor/bin/codecept run unit
php vendor/bin/codecept run functional
php vendor/bin/codecept run acceptance

<span class="hljs-comment"># Run a specific test file
php vendor/bin/codecept run unit Tests/Unit/OrderCalculatorTest

<span class="hljs-comment"># Run a specific test method
php vendor/bin/codecept run unit Tests/Unit/OrderCalculatorTest:testSubtotalIsCalculatedCorrectly

<span class="hljs-comment"># Generate HTML report
php vendor/bin/codecept run --html

<span class="hljs-comment"># Run in parallel (requires codeception/module-rest)
php vendor/bin/codecept run -g api --steps

<span class="hljs-comment"># Verbose output
php vendor/bin/codecept run --steps --debug

The HTML report lands in tests/_output/report.html and shows pass/fail per suite with step-by-step logs for each test.

Failed test re-run (useful during development):

php vendor/bin/codecept run -g failed

Codeception records failed tests and lets you rerun only those with the -g failed flag.

CI Integration with GitHub Actions

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  unit-functional:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: myapp_test
        ports: ['3306:3306']
        options: --health-cmd="mysqladmin ping" --health-interval=10s

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo_mysql, mbstring

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist

      - name: Run unit tests
        run: php vendor/bin/codecept run unit --no-exit

      - name: Run functional tests
        run: php vendor/bin/codecept run functional --no-exit
        env:
          DB_HOST: 127.0.0.1
          DB_DATABASE: myapp_test
          DB_USERNAME: root
          DB_PASSWORD: root

  acceptance:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo_mysql, mbstring

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist

      - name: Start application
        run: php artisan serve --host=0.0.0.0 --port=8000 &
        env:
          APP_ENV: testing

      - name: Run acceptance tests
        uses: nick-fields/retry@v2
        with:
          timeout_minutes: 10
          max_attempts: 2
          command: php vendor/bin/codecept run acceptance --no-exit

      - name: Upload test artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: codeception-output
          path: tests/_output/

The --no-exit flag prevents Codeception from returning a non-zero exit code on test failures during the upload step — remove it if you want the CI job to fail on test failure (you usually do).

Tips for Laravel and Symfony Projects

Laravel: Enable refreshDatabase in the Laravel module to wrap each test in a transaction that rolls back automatically — no dump needed for most tests:

modules:
  enabled:
    - Laravel:
        environment_file: .env.testing
        cleanup: true   # wraps each test in a transaction

Symfony: Use the Symfony module and set APP_ENV=test. The module boots the kernel for each test and gives you access to the service container:

$service = $this->tester->grabService(MyService::class);

Both frameworks benefit from separating fast tests (unit + functional) from slow tests (acceptance) into different CI stages so developers get feedback quickly.

Beyond the Basics

Codeception supports several advanced patterns worth knowing:

  • PageObjects: Encapsulate UI selectors in a class so selector changes propagate from one place
  • StepObjects: Group related actions into reusable actor methods
  • Environments: Override suite configuration per environment (--env staging)
  • Groups: Tag tests with @group annotations and run subsets with -g groupname
  • Cest format: Alternative class format that doesn't extend any base class, useful for acceptance tests

Wrapping Up

Codeception solves a real problem: PHP projects have historically needed multiple testing tools for different layers, each with its own conventions. By unifying unit, functional, and acceptance tests under one runner and one configuration format, Codeception reduces the overhead of maintaining a comprehensive test suite and lowers the barrier for developers to write tests at the right level of abstraction.

The framework's module system — with first-class support for Laravel, Symfony, database access, REST APIs, and browser automation — means you rarely need to reach outside Codeception to handle common testing scenarios.

HelpMeTest complements Codeception by providing 24/7 monitoring, AI-powered test generation, and health checks that run continuously against your production and staging environments — catching regressions between CI runs. Start free at helpmetest.com.

Read more