Laravel Testing with Pest — Part 1 · Part 2 · Part 3 · Part 4 · Part 5
Tests are a second application. They need a database, cache, mailer, queue and filesystem that cannot accidentally touch development data. This first part builds that boundary around one small order domain which we will keep through the series: a customer places an order, stock is reserved, and an order may be paid. Part 2 drives it through HTTP, Part 3 through a browser, and the final parts make time and CI deterministic.
The goal is not to make every test use a database. The goal is to make the tests that do use it boring: a reader should see the business situation in the first few lines and not have to decode incidental setup.
Give tests their own services
Laravel reads values from phpunit.xml while running tests. Use services that are disposable locally and in CI. SQLite in memory is fast, but it is not a perfect stand-in for MySQL or PostgreSQL: JSON, collations, locking and SQL features can differ. Use it for fast application feedback only when the application's queries are portable; otherwise run the test database in the same engine as production.
<!-- phpunit.xml -->
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
</php>
sync is useful while asserting a simple side effect, but do not mistake it for queue coverage: a job that only works synchronously may still fail after serialization on a worker. In Part 4 we will use Queue::fake() when the contract is “the job was dispatched”, and integration tests when the worker behaviour itself matters.
Keep shared setup visible in tests/Pest.php:
<?php
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
uses(Tests\TestCase::class, LazilyRefreshDatabase::class)->in('Feature');
LazilyRefreshDatabase migrates only when a test actually touches the database. Prefer it to manually truncating tables. A test that creates a model but omits the trait may pass alone and leak state in the suite.
Model the order story in factories
Factories are a vocabulary, not a random-data generator. Start with defaults that represent a valid, ordinary object. Then name the exceptional business states a test wants to talk about.
<?php
namespace Database\Factories;
use App\Models\Customer;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<Order> */
final class OrderFactory extends Factory
{
public function definition(): array
{
return [
'customer_id' => Customer::factory(),
'number' => fake()->unique()->numerify('ORD-#####'),
'status' => 'draft',
'total_cents' => 0,
'placed_at' => null,
'paid_at' => null,
];
}
public function placed(): static
{
return $this->state(fn (): array => ['status' => 'placed', 'placed_at' => now()]);
}
public function paid(): static
{
return $this->placed()->state(fn (): array => ['status' => 'paid', 'paid_at' => now()]);
}
public function withLine(Product $product, int $quantity = 1): static
{
return $this->afterCreating(function (Order $order) use ($product, $quantity): void {
$order->lines()->create(['product_id' => $product->id, 'quantity' => $quantity, 'unit_price_cents' => $product->price_cents]);
});
}
}
The withLine() helper deliberately creates a persisted relationship after the order exists. A relationship factory is equally good when its structure is simple; use the approach that keeps the call site clearest. Do not make the default factory create lines, payments and notifications “just in case”. That turns a one-row fixture into hidden I/O and makes unrelated tests slow.
<?php
use App\Models\Order;
use App\Models\Product;
it('builds a paid order with an explicit line', function (): void {
$product = Product::factory()->create(['price_cents' => 2_500]);
$order = Order::factory()->paid()->withLine($product, quantity: 2)->create();
expect($order->status)->toBe('paid')
->and($order->lines)->toHaveCount(1)
->and($order->lines->first()->quantity)->toBe(2);
});
Keep intent at the call site
Avoid $order = Order::factory()->create(['status' => 'paid', 'paid_at' => now()]);. It duplicates the meaning of paid throughout the suite. When the definition changes—for example it needs a payment reference—old tests silently create invalid objects. ->paid() provides one migration point and reads like the domain. Conversely, do not create states for one-off cosmetic overrides: ->state(['number' => 'ORD-42']) is clearer than ->numberFortyTwo().
Datasets are useful only for a repeated rule, not as a way to hide a scenario:
it('rejects invalid quantities', function (int $quantity): void {
expect($quantity)->toBeLessThan(1);
})->with([-1, 0]);
What this setup does not test
Factories do not prove migrations, browser flows, worker serialization or a real payment provider. They make focused feature tests possible. Do not use LazilyRefreshDatabase for a pure value object or pricing calculation; those tests should construct objects directly and run in milliseconds. And do not force factories into a legacy domain with no stable invariants: a small test builder may be the more honest boundary.
In Part 2, the same paid() and withLine() language will make an HTTP test about authorization and responses, not about assembling an order.
Test the factory contract itself
A factory is production-adjacent test infrastructure. Give important states a
small regression test, particularly when an afterCreating callback creates
relationships or updates totals. This catches the misleading situation where a
feature test fails because its fixture became invalid rather than because the
feature regressed.
<?php
use App\Models\Order;
use App\Models\Product;
it('creates a line at the product price', function (): void {
$product = Product::factory()->create(['price_cents' => 1_999]);
$order = Order::factory()->withLine($product, quantity: 3)->create();
expect($order->fresh()->lines()->firstOrFail())
->unit_price_cents->toBe(1_999)
->quantity->toBe(3);
});
Use fixed values whenever the assertion concerns arithmetic, expiry, sort order or a public response. Faker is excellent for fields whose content is irrelevant but a poor way to hide a test's input. If a failing test cannot tell a reader which price, customer or date matters, it is harder to debug than it needs to be. Do not use this structure as an excuse for database-heavy unit tests. A pure order-total calculator should receive an in-memory line collection. A feature test should prove Eloquent wiring, policies and persistence. That distinction keeps the eventual suite fast enough to run before every commit.
Run the smallest relevant command while writing and the series' broader suite before merging:
docker compose exec app php artisan test --compact tests/Feature/OrderFactoryTest.php
docker compose exec app php artisan test --compact --filter='paid order'