Bekzod Erkinov
Listen to Article
Loading...Table of contents · 9 sections
Laravel Testing Strategy: What to Unit, Feature and Browser Test
A Laravel testing strategy has to answer one question: for this behaviour, which layer should test it? I built a small Laravel 12 app with Pest, injected three bugs one at a time, and watched which layer caught each. A pure-math bug turned only the unit test red, a validation bug only the feature test, and a JavaScript bug only the browser test. I also measured what the layers cost and whether the browser tests were flaky. In a Linux container under Docker Desktop on Windows 11 (nproc printed 8), a unit test cost about 0.56 ms, a feature test 13 to 18 ms and a browser test about 0.74 s, so roughly 1 : 24-32 : 1,300. Across 416 browser-test runs I saw zero failures. Every file is below, so you can reproduce all of it.
Versions, from composer show and npx playwright --version in the container on 2026-10-12: PHP 8.4.26, Laravel 12.69.3, Pest 4.7.8, pest-plugin-browser 4.3.1, brianium/paratest 7.20.0, Playwright 1.64.0. It's one small app on one machine, so read the timings as ratios.
Unit vs feature vs browser tests in Laravel: when to use each
| Question | Layer |
|---|---|
| Is it a pure function with edge cases? | Unit |
| Does it involve a request, the database, auth or validation? | Feature |
| Does it depend on JavaScript or on what a real page renders? | Browser |
| Could a lower layer assert the same thing? | Use the lower layer |
The Laravel 12 testing docs say "Generally, most of your tests should be feature tests." They also say tests in the Unit directory "do not boot your Laravel application", while "Laravel boots the application for each individual test method" in feature tests, which is where the cost gap below comes from.
Which layer catches which bug
Timings tell you what a layer costs, not what it buys. So I injected three bugs one at a time with sed into the app listed in the last section, ran each layer's directory against each, and restored the original files after each one.
| Injected bug | Unit | Feature | Browser |
|---|---|---|---|
A. Calculator rounds the discount up: intdiv(...) replaced by (int) ceil(...) |
FAIL | pass | pass |
B. Controller drops max:100 from the qty rule |
pass | FAIL | pass |
| C. Page JS no longer listens on the coupon field | pass | pass | FAIL |
| None (baseline, and again after restoring) | pass | pass | pass |
The script printed this:
baseline: Unit=pass Feature=pass Browser=pass
A: Unit=FAIL Feature=pass Browser=pass
B: Unit=pass Feature=FAIL Browser=pass
C: Unit=pass Feature=pass Browser=FAIL
restored: Unit=pass Feature=pass Browser=pass
The failure lines, trimmed.
Bug A (return $gross - (int) ceil($gross * $discountPercent / 100);):
FAILED Tests\Unit\PriceCalculatorTest > it applies a whole-percent disco…
Failed asserting that 299 is identical to 300.
at tests/Unit/PriceCalculatorTest.php:11
333 cents at 10% off: the exact discount is 33.3. intdiv gives 33, so the total is 300. ceil gives 34, so the total is 299. The feature test posts 4 pens at 250 cents: gross 1000, the exact discount is 100.0, so ceil and intdiv agree and it stays green. Only an input where the discount isn't a whole number exposes the change, and those are cheap to enumerate in a unit test and awkward to reach through HTTP.
Bug B ('max:100' removed):
FAILED Tests\Feature\OrderTest > it rejects a quantity over 100
Expected response status code [422] but received 201.
Validation, routing, the database and serialization meet in one request, and a feature test exercises all of them.
Bug C (the browser test, with the failing step highlighted by Pest):
Expected to see text [9.00] within element [#preview] on the page initially with the url [http://127.0.0.1:58467/checkout], but it was not found or not visible.
at tests/Browser/CheckoutTest.php:14
➜ 14▕ ->assertSeeIn('#preview', '9.00')
Typing in the coupon field no longer triggers update(), so the preview stays at the old value. That bug lives in JavaScript the server never executes; no PHP test can see it, which is the whole case for a browser test.
What each layer costs
Whole-process cost first. This is bench.sh: it runs each directory as its own pest process 30 times and times the whole process, so PHP start-up and framework boot are included.
run() { # name path n
ok=0; fail=0; : > /tmp/$1.times
i=0
while [ $i -lt $3 ]; do
s=$(date +%s.%N)
if ./vendor/bin/pest $2 >/tmp/$1.last 2>&1; then ok=$((ok+1)); else fail=$((fail+1)); fi
e=$(date +%s.%N)
awk "BEGIN{print $e-$s}" >> /tmp/$1.times
i=$((i+1))
done
sort -n /tmp/$1.times | awk -v n="$1" -v ok=$ok -v fail=$fail '{a[NR]=$1; s+=$1} END{printf "%s runs=%d pass=%d fail=%d min=%.2f median=%.2f max=%.2f mean=%.2f\n", n, NR, ok, fail, a[1], a[int((NR+1)/2)], a[NR], s/NR}'
}
run unit tests/Unit 30
run feature tests/Feature 30
run browser tests/Browser 30
unit runs=30 pass=30 fail=0 min=0.19 median=0.22 max=0.31 mean=0.23
feature runs=30 pass=30 fail=0 min=0.49 median=0.57 max=1.06 mean=0.60
browser runs=30 pass=30 fail=0 min=3.48 median=3.73 max=4.82 mean=3.79
(The tests/Unit and tests/Feature directories include the two skeleton ExampleTest files; tests/Browser holds the two browser tests listed below.) Process start-up dominates those numbers, so for the marginal cost per test I repeated one test with Pest's ->repeat() in a throwaway file and compared repeat(1) against a large repeat. A pure unit test has to live under tests/Unit: my first attempt put it in a directory bound to Laravel's TestCase, and it ran far slower because it booted the app for every repeat. The numbers below are Pest's own Duration, three runs each:
Unit repeat(1): 0.11s 0.12s 0.13s
Unit repeat(5001): 2.96s 2.93s 2.85s
Feature repeat(1): 0.47s 0.47s 0.42s
Feature repeat(501): 9.80s 9.32s 9.05s
Browser repeat(1): 3.33s 3.85s 3.35s
Browser repeat(101): 87.25s 71.09s 75.43s
Dividing the difference of the means by the extra repeats gives (2.91 - 0.12) / 5000 = 0.56 ms per unit test, (9.39 - 0.45) / 500 = 17.9 ms per feature test and (77.9 - 3.5) / 100 = 0.74 s per browser test. A separate Feature repeat(201) took 3.08 s and 3.13 s, which is about 13 ms per test, so I quote 13 to 18 ms for a feature test. The unit test is a call to a pure function; a real unit test with more setup costs more. The ratio is about 1 : 24-32 : 1,300.
Which number to use: for a large suite, the marginal figures. For a tiny suite like this one, the whole-process medians (0.22 s, 0.57 s, 3.73 s) are what you actually wait for.
Why my timings looked inconsistent, and what --parallel did
Pest's Duration line covers the tests; it does not include PHP start-up. The time wall clock does. Same 11 tests (the 9 I wrote plus the 2 skeleton examples), three runs each, wall clock then Pest's Duration:
| Command | Wall clock (s) | Pest Duration (s) |
|---|---|---|
./vendor/bin/pest |
0.86, 1.03, 0.90 | 0.70, 0.88, 0.74 |
./vendor/bin/pest tests/Unit tests/Feature |
1.05, 1.04, 1.10 | 0.91, 0.87, 0.92 |
./vendor/bin/pest --parallel |
2.00, 1.98, 2.10 | 1.24, 1.23, 1.36 |
php artisan test |
1.22, 1.29, 1.46 | 0.71, 0.78, 0.73 |
php artisan test --parallel |
3.21, 2.61, 3.25 | 1.67, 1.20, 1.55 |
On 11 tests that take well under a second, --parallel was slower, not faster, in my runs. The Pest browser docs say "We recommend running tests in parallel using the --parallel option" for browser tests, and the flake runs below use it.
How flaky are the browser tests?
I wrote a second, more realistic browser test than the first. CheckoutTest fills a form and reads the result. CouponTest clicks a button, the page calls fetch('/coupons/SAVE10'), waits 300 ms (standing in for an animation or slow UI) and only then writes "Coupon applied", so the assertion has to wait for async work.
I ran each test 104 times: 8 files, each with ->repeat(13), under pest --parallel (8 paratest processes), once with the container at 8 CPUs and once after docker update --cpus 1 lt, which squeezes the same 8 workers onto one CPU. The generator:
for i in 1 2 3 4 5 6 7 8; do
for kind in Checkout Coupon; do
sed -e "s/^});\$/})->repeat(13);/" tests/Browser/${kind}Test.php > tests/Flake/${kind}${i}Test.php
done
done
./vendor/bin/pest tests/Flake --parallel --filter='price preview' # CheckoutTest copies
./vendor/bin/pest tests/Flake --parallel --filter='coupon status' # CouponTest copies
| Test | Container CPUs | Failures / runs | Pest Duration |
|---|---|---|---|
CheckoutTest |
8 | 0 / 104 | 48.41 s |
CouponTest (async) |
8 | 0 / 104 | 37.12 s |
CheckoutTest |
1 | 0 / 104 | 185.20 s |
CouponTest (async) |
1 | 0 / 104 | 144.04 s |
Zero failures in 416 runs. That is what I saw for these two tests on this machine; it does not prove a rate of zero, and a suite with real data, third-party scripts or slower pages may behave differently. Pest's built-in waiting handled the 300 ms delay without a manual sleep; I added none.
Decision rules, per layer
Write a unit test when the code is a pure function or small class with branching you can enumerate: money math, rounding, parsing, state machines, date windows. The question: can I construct it with new and no database? Don't unit-test controllers or Eloquent models by mocking everything around them; you end up with a fast test of your mocks.
Write a feature test when the behaviour crosses the HTTP boundary: validation rules, authorization, status codes, redirects, what gets persisted, which job or notification fires. Make this your default layer for new behaviour, which is also what the Laravel docs recommend.
Write a browser test when the thing that can break is the browser's own behaviour and no server-side test can see it: JavaScript that computes or toggles something, multi-step flows where client state matters, file uploads, drag and drop. Cap it at the few journeys where being broken costs money, such as sign-up, checkout or login.
What each layer should not assert:
- Unit: no database, no HTTP, no mocks of the framework.
- Feature: nothing that depends on JavaScript running; it never runs.
- Browser: no status codes, per-rule validation messages or database rows that a feature test could check.
Moving an assertion down: a run example
Before: a browser test that checks a rule the server owns.
<?php // tests/Browser/QtyTest.php
it('shows a validation error for a quantity over 100', function () {
visit('/checkout')
->fill('qty', '101')
->press('Place order')
->assertSee('must not be greater than 100');
});
It passes (✓ ... 1.04s), and so does the feature test it rejects a quantity over 100 (✓ ... 0.28s). Now inject bug B again. Both go red, but at very different cost:
⨯ it shows a validation error for a quantity over 100 6.11s
Expected to see text [must not be greater than 100] on the page initially ...
⨯ it rejects a quantity over 100 0.02s
The browser test spent 6.11 s waiting for text that never came, the feature test failed in 0.02 s with a clearer message (Expected response status code [422] but received 201.). After: delete QtyTest.php. The rule is still covered by the feature test, and pest tests/Browser is back to Tests: 2 passed (5 assertions).
Pitfalls I actually hit
pest-plugin-browserneeds the PHPsocketsextension. Without it,composer require pestphp/pest-plugin-browser --dev -Wstopped withCannot use pestphp/pest-plugin-browser's latest version v5.1.2 as it requires ext-sockets * which is missing from your platform.The Dockerfile below installs it. Withsocketspresent, Composer chose v4.3.1 alongside Pest 4.7.8.- A
tests/Browserdirectory is not a suite. A fresh install registers onlyUnitandFeatureinphpunit.xml. In the full app below, bare./vendor/bin/pestreportsTests: 11 passed (19 assertions)and never runs the browser tests;./vendor/bin/pest tests/Browserruns them. Either add a third<testsuite name="Browser">tophpunit.xmlor runtests/Browserexplicitly in CI. - The generated
tests/Pest.phpbindsTestCaseonly toFeature. Before I widened it,pest tests/Browserfailed withBindingResolutionException: Target class [config] does not exist.The fix is one line:
pest()->extend(TestCase::class)
->in('Feature', 'Browser');
pest tests/Unit tests/Feature --parallelis rejected (Too many arguments, expected arguments "path".); with--parallelpass a single path or none.RefreshDatabaseworked with browser tests in my runs; untested with MySQL or Postgres.DB_DATABASEwas:memory:fromphpunit.xml, and the browser test's last line reads the order back from the test process (Order::first()) after the page created it. It passed in every run above. The Pest browser docs' sign-in example has the comment "assumes RefreshDatabase trait is used on Pest.php", so Pest's plugin expects it. The Laravel Dusk docs say the opposite for Dusk: "your Dusk tests should never use theRefreshDatabasetrait" and "SQLite in-memory databases may not be used when executing Dusk tests". They are different tools, so check your own setup.
Reproduce it: Docker setup and the complete app
Docker
The Dockerfile (PHP 8.4 base, sockets for the browser plugin, Playwright with Chromium):
FROM php:8.4-cli
RUN apt-get update && apt-get install -y --no-install-recommends git unzip curl ca-certificates nodejs npm libzip-dev && docker-php-ext-install pcntl zip sockets >/dev/null && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN npm install -g [email protected] && playwright install --with-deps chromium
WORKDIR /work
The commands, run from the Dockerfile's directory:
docker build -t laravel-pest-browser .
docker run -d --name lt -v lt-work:/work laravel-pest-browser sleep infinity
docker exec lt bash -c 'cd /work && composer create-project "laravel/laravel:^12.0" app'
docker exec lt bash -c 'cd /work/app && composer remove phpunit/phpunit --dev -W \
&& composer require pestphp/pest pestphp/pest-plugin-laravel --dev -W \
&& ./vendor/bin/pest --init \
&& composer require pestphp/pest-plugin-browser --dev -W \
&& npm install [email protected] && npx playwright install --with-deps chromium'
docker exec lt bash -c 'cd /work/app && ./vendor/bin/pest tests/Browser'
docker update --cpus 1 lt # the CPU-constrained runs; --cpus 8 to undo
The code lives in a named volume (lt-work), not a bind mount. I passed no --no-sandbox flag and started no web server or Chromium myself; the plugin did, and the failure messages above show it serving the app on 127.0.0.1 at a random port. The Pest browser testing docs list the same plugin steps: composer require pestphp/pest-plugin-browser --dev, npm install playwright, npx playwright install. If you want Laravel itself in a container, see Dockerizing a Laravel App the Right Way.
What a fresh Laravel 12 install contains
I ran composer create-project "laravel/laravel:^12.0" app and printed the files. The require-dev block of composer.json (PHPUnit, no Pest):
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.50"
},
phpunit.xml registers two suites and sets DB_CONNECTION to sqlite and DB_DATABASE to :memory::
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
...
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
tests/ holds TestCase.php, Feature/ExampleTest.php and Unit/ExampleTest.php; there is no tests/Pest.php and no tests/Browser. After the Pest steps above, require-dev lists pestphp/pest ^4.7, pestphp/pest-plugin-browser ^4.3 and pestphp/pest-plugin-laravel ^4.1 instead of phpunit/phpunit, and diff of phpunit.xml against the fresh one printed phpunit.xml: no diff vs fresh.
The app under test (complete)
Three pieces, one per layer: a pure price calculator, POST /orders with validation, and a checkout page with a few lines of JavaScript that previews the price and checks a coupon. Copy these into a fresh Laravel 12 project and the tests run.
<?php // app/Support/PriceCalculator.php
namespace App\Support;
use InvalidArgumentException;
class PriceCalculator
{
/** Totals are integer cents. Discount is a whole percent. */
public function total(int $unitCents, int $qty, int $discountPercent = 0): int
{
if ($qty < 1 || $discountPercent < 0 || $discountPercent > 100) {
throw new InvalidArgumentException('bad input');
}
$gross = $unitCents * $qty;
return $gross - intdiv($gross * $discountPercent, 100);
}
}
<?php // database/migrations/2026_01_01_000000_create_orders_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('sku');
$table->unsignedInteger('qty');
$table->unsignedTinyInteger('discount_percent')->default(0);
$table->unsignedInteger('total_cents');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
<?php // app/Models/Order.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $fillable = ['sku', 'qty', 'discount_percent', 'total_cents'];
}
<?php // routes/web.php
use App\Http\Controllers\OrderController;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => view('welcome'));
Route::get('/checkout', [OrderController::class, 'create']);
Route::post('/orders', [OrderController::class, 'store']);
Route::get('/coupons/{code}', fn (string $code) => ['valid' => $code === 'SAVE10']);
<?php // app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Support\PriceCalculator;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public const PRICES = ['pen' => 250, 'notebook' => 1200];
public function create()
{
return view('checkout', ['prices' => self::PRICES]);
}
public function store(Request $request, PriceCalculator $calc)
{
$data = $request->validate([
'sku' => ['required', 'in:'.implode(',', array_keys(self::PRICES))],
'qty' => ['required', 'integer', 'min:1', 'max:100'],
'coupon' => ['nullable', 'string'],
]);
$discount = ($data['coupon'] ?? null) === 'SAVE10' ? 10 : 0;
$total = $calc->total(self::PRICES[$data['sku']], (int) $data['qty'], $discount);
$order = Order::create([
'sku' => $data['sku'], 'qty' => $data['qty'],
'discount_percent' => $discount, 'total_cents' => $total,
]);
if ($request->wantsJson()) {
return response()->json($order, 201);
}
return redirect('/checkout')->with('status', 'Order #'.$order->id.' total '.number_format($total / 100, 2));
}
}
{{-- resources/views/checkout.blade.php --}}
<!doctype html>
<html><head><meta charset="utf-8"><title>Checkout</title></head>
<body>
@if (session('status'))<p id="flash">{{ session('status') }}</p>@endif
@if ($errors->any())<p id="errors">{{ $errors->first() }}</p>@endif
<form method="POST" action="/orders">
@csrf
<select name="sku" id="sku">
@foreach ($prices as $sku => $cents)<option value="{{ $sku }}">{{ $sku }}</option>@endforeach
</select>
<input name="qty" id="qty" type="number" value="1">
<input name="coupon" id="coupon" placeholder="coupon">
<p>Preview: <span id="preview">2.50</span></p>
<button type="submit">Place order</button>
<button type="button" id="check">Check coupon</button> <span id="coupon-status"></span>
</form>
<script>
const prices = @json($prices);
function update() {
const sku = document.getElementById('sku').value;
const qty = parseInt(document.getElementById('qty').value || '0', 10);
const off = document.getElementById('coupon').value === 'SAVE10' ? 10 : 0;
const gross = prices[sku] * qty;
document.getElementById('preview').textContent = ((gross - Math.floor(gross * off / 100)) / 100).toFixed(2);
}
document.getElementById('check').addEventListener('click', async () => {
const res = await fetch('/coupons/' + encodeURIComponent(document.getElementById('coupon').value));
const body = await res.json();
await new Promise(r => setTimeout(r, 300)); // stands in for an animation or slow UI
document.getElementById('coupon-status').textContent = body.valid ? 'Coupon applied' : 'Unknown coupon';
});
['sku', 'qty', 'coupon'].forEach(id => document.getElementById(id).addEventListener('input', update));
</script>
</body></html>
The unit price of a pen is 250 cents, so 4 pens with SAVE10 is 1000 cents gross, 100 off, 900 total. The tests, one file per layer plus the async browser test:
<?php // tests/Unit/PriceCalculatorTest.php
use App\Support\PriceCalculator;
it('multiplies unit price by quantity', function () {
expect((new PriceCalculator)->total(250, 3))->toBe(750);
});
it('applies a whole-percent discount, rounding the discount down', function () {
expect((new PriceCalculator)->total(250, 1, 10))->toBe(225);
expect((new PriceCalculator)->total(333, 1, 10))->toBe(300); // discount is intdiv(3330, 100) = 33
});
it('rejects bad input', function (int $qty, int $pct) {
(new PriceCalculator)->total(100, $qty, $pct);
})->with([[0, 0], [1, -1], [1, 101]])->throws(InvalidArgumentException::class);
<?php // tests/Feature/OrderTest.php
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('creates an order and stores the discounted total', function () {
$this->postJson('/orders', ['sku' => 'pen', 'qty' => 4, 'coupon' => 'SAVE10'])
->assertCreated()
->assertJsonPath('total_cents', 900);
expect(Order::count())->toBe(1);
});
it('rejects an unknown sku', function () {
$this->postJson('/orders', ['sku' => 'laptop', 'qty' => 1])
->assertUnprocessable()
->assertJsonValidationErrors('sku');
});
it('rejects a quantity over 100', function () {
$this->postJson('/orders', ['sku' => 'pen', 'qty' => 101])->assertUnprocessable();
expect(Order::count())->toBe(0);
});
it('redirects form posts back to the checkout with a flash message', function () {
$this->post('/orders', ['sku' => 'notebook', 'qty' => 1])
->assertRedirect('/checkout')
->assertSessionHas('status');
});
<?php // tests/Browser/CheckoutTest.php
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('updates the price preview as the user types and places the order', function () {
$page = visit('/checkout');
$page->assertSee('Preview')
->fill('qty', '4')
->fill('coupon', 'SAVE10')
->assertSeeIn('#preview', '9.00')
->press('Place order')
->assertSee('Order #1 total 9.00');
expect(Order::first()->total_cents)->toBe(900);
});
<?php // tests/Browser/CouponTest.php
it('shows the coupon status after the async check', function () {
visit('/checkout')
->fill('coupon', 'SAVE10')
->press('Check coupon')
->assertSee('Coupon applied');
});
If you are building the API side of an app like this, the Laravel REST API series is the next thing to write feature tests against.
Limits
I tested Pest 4.7.8, one small app, one machine, and no database other than in-memory SQLite. The flake runs cover two browser tests in Chromium only, 104 runs each per configuration. The unit cost is for a trivial pure function.
What to do next
- Run
php artisan test --profileon your suite and write down the median time per Unit, Feature and Browser test. Your ratios matter more than mine. - Open
phpunit.xmland check that every test directory you rely on is a registered suite. A fresh install registers onlyUnitandFeature. - Pick the three journeys that cost you money when broken and give each one browser test. Move every assertion a feature test could make out of those tests.
- For each bug you fix this month, write the regression test at the lowest layer that can see it.
- Break one rule on purpose in your own code and see which layer goes red. A change that turns nothing red is a gap.
Keep reading
Mastering Multi-Tenant SaaS Architecture Patterns for Enterprise Applications
27 min · 851 views
Web Development10 Essential Tools for Modern Frontend Development
16 min · 797 views
Web DevelopmentGraceful Shutdown Patterns for Laravel Queue Workers in Production
21 min · 239 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log In