Ask an AI to write tests for a class and in a few seconds you get ten green tests. It feels great, but the real question is: if someone breaks this class tomorrow, will these tests turn red?

An automated test is code that runs and checks the behavior of other code, so you find out when that behavior changes. A test that does not turn red when the code breaks only gives you a false sense of safety. This article is about getting tests that actually measure something.

What traps do AI-written tests have?#

There are three main traps: repeating the code's logic inside the test, locking in existing bugs, and mocking too much.

1. The mirror test#

The test recomputes the same logic as the code. If the logic is wrong, the test agrees with the same mistake:

PHP
it('calculates shipping', function () {
    $shipping = new ShippingCost(freeFrom: 500_000, flatRate: 45_000);
    $total = 300_000;
    $expected = $total >= 500_000 ? 0 : 45_000;

    expect($shipping->calculate($total))->toBe($expected);
});

A good test writes the expected number directly: toBe(45_000).

2. Locking in a bug#

When you give the model only the code, it assumes the code is correct and writes the current output into the test as "correct". If the code has a bug, you now own a test that defends the bug.

3. Mocking everything#

Every dependency is replaced with a fake (a mock), so the test only checks that the mocks returned what they were told to return.

Mocks are for things that are slow or external: a payment gateway, an SMS service, someone else's API. For the database in Laravel, a real test database with factories is usually better than a mock. If the model mocked everything, ask: "Which mocks are actually necessary?"

There is one smaller trap too: a test with no real assertion, one that just runs the code and never compares anything with an expectation. Such a test is always green.

How do you get useful tests?#

Give the expected behavior in plain language first, get the list of cases and approve it, and only then ask for the test code.

The six-step method for writing tests with AI: from expected behavior to deliberately breaking the code to check the test
  1. Describe the behavior, not just the code. "Shipping is free for orders of 500,000 or more, a flat rate applies below that, and a negative amount is an error."
  2. Ask for the list of cases without code.
  3. Review the list and add missing cases: the exact boundary, zero, empty input, a very large number.
  4. Ask for the test code and give an existing test file from the project as a style example.
  5. Run it.
  6. Break the main code on purpose and see whether the test turns red.

The request for step two could look like this:

Prompt
This class calculates shipping cost. Expected behavior:
- Orders of 500,000 or more: free shipping
- Anything below that: a flat rate of 45,000
- Negative amount: throw an InvalidArgumentException

Do not write code yet. Give me the cases that need testing in a table:
input, expected output, and what each case verifies.

Example: a unit test with Pest#

A unit test checks a single class or function in isolation from the rest of the application. This is the class under test:

PHP
<?php

namespace App\Services;

use InvalidArgumentException;

final class ShippingCost
{
    public function __construct(
        private int $freeFrom,
        private int $flatRate,
    ) {}

    public function calculate(int $orderTotal): int
    {
        if ($orderTotal < 0) {
            throw new InvalidArgumentException('Order total cannot be negative.');
        }

        return $orderTotal >= $this->freeFrom ? 0 : $this->flatRate;
    }
}

And these are the tests written after the list of cases was approved:

PHP
<?php

use App\Services\ShippingCost;

beforeEach(function () {
    $this->shipping = new ShippingCost(freeFrom: 500_000, flatRate: 45_000);
});

it('is free at exactly the threshold', function () {
    expect($this->shipping->calculate(500_000))->toBe(0);
});

it('is free above the threshold', function () {
    expect($this->shipping->calculate(2_000_000))->toBe(0);
});

it('charges the flat rate below the threshold', function (int $total) {
    expect($this->shipping->calculate($total))->toBe(45_000);
})->with([0, 1, 499_999]);

it('rejects a negative total', function () {
    $this->shipping->calculate(-1);
})->throws(InvalidArgumentException::class);

The cases 499_999 and 500_000 sit exactly on the boundary. Now step six: change >= to > in the class and run php artisan test. The test "is free at exactly the threshold" should turn red. If it does not, you have not tested the boundary. Mutation testing tools such as Infection do the same thing automatically across the whole codebase.

How is a Laravel feature test different?#

A feature test runs the whole request path, so the model needs to see your routes, models and authentication setup.

Include the routes file, the model factory and the related policy with your request. Here is a PHPUnit example, assuming the project has an /orders/{order} route and an order factory:

PHP
<?php

namespace Tests\Feature;

use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderAccessTest extends TestCase
{
    use RefreshDatabase;

    public function test_guest_is_redirected_to_login(): void
    {
        $order = Order::factory()->create();

        $this->get("/orders/{$order->id}")->assertRedirect('/login');
    }

    public function test_user_cannot_view_another_users_order(): void
    {
        $order = Order::factory()->create();
        $stranger = User::factory()->create();

        $this->actingAs($stranger)
            ->get("/orders/{$order->id}")
            ->assertForbidden();
    }
}

If your project returns 404 in this situation so it does not even reveal that the order exists, the test should use assertNotFound(). That decision is yours, not the model's.

Which tests should you start with?#

Start where failures are expensive: money calculations, user access, and bugs that have already happened once.

A project with no tests does not need full coverage overnight. This order usually pays off the most:

  1. Anything that touches money: prices, discounts, taxes, shipping costs.
  2. Access control: each user sees only their own data, and a regular user cannot reach the admin area.
  3. Past bugs: for every bug you fix, first write a test that reproduces it. Bugs confirmed in an AI code review belong here too.
  4. Core flows: sign-up, login, placing an order.

AI helps with all of these, but deciding which behavior is "correct" is always your job.

What should you do when a test turns red?#

First decide whether the fault is in the code or in the test, and only then ask the AI for help.

If you just write "make the test green", the model, especially when it works as an agent, may take the easiest path: changing the test itself. So be explicit:

Prompt
The tests are correct and must not be changed. Fix only the code in
app/Services so the tests pass. Before changing anything, explain
why the test fails.

To find the cause of a failure, the same techniques from debugging with AI apply.

Where is AI test writing heading?#

The direction is clear: coding agents run tests themselves and continue their work based on the result, so tests turn from "extra work" into the definition of done.

The more automated the tools become, the more your test quality decides how far you can trust their output. To get ready, test the important behaviors of your project now, keep the test suite fast, and make tests mandatory in your merge process. See working with agents in coding agents, and using AI across a whole project in AI in a Laravel project.

Summary#

  • Watch out for mirror tests, locked-in bugs and too many mocks.
  • Give the behavior first and get the list of cases, then the test code.
  • Test boundaries explicitly and write the expected number directly.
  • Break the code on purpose; a test that never turns red is useless.
  • When a test turns red, first decide whether the code or the test is wrong.