# 5 Tips for Writing Good Agentic Tests with e2e

> Five tips for end-to-end tests your team can trust. Describe user goals instead of implementation details, wait for outcomes instead of sleeping, assert specific results, keep tests isolated, and make failures easy to debug, with examples in e2e.

By Kacper Kapuściak. Published 2026-11-13. Canonical URL: https://e2e.com/blog/5-tips-for-writing-good-agentic-tests

A good end-to-end test suite is a lot like a well-maintained car. You can trust that it won't fail you when you need it the most. Nobody plans a road trip in a car they don't trust.

Tests work the same way. You need an end-to-end testing suite you can rely on. You make a change, the tests go green, and you have confidence that the flows you tested still work. That's the whole point!

Once the team stops trusting the suite, it starts to decay. A red build gets rerun instead of read. A failing test gets skipped "just for now." A few months later, nobody remembers what half of these tests even check.

Agentic testing uses AI agents to carry out user flows in your app. For automated UI testing, that means describing the critical business flow and letting an agent figure out the interactions. You still need to decide what to assert and what counts as success.

Here are 5 tips on how to write end-to-end tests that are easy to maintain, with examples using [e2e](https://e2e.com). We'll use a project management app where users generate monthly activity reports.

## Don't test the implementation

Your user wants to generate a monthly report. They don't care whether the date picker uses a Material UI component or how many nested elements sit inside the "Generate" button.

Tests tied to those details break when you change the implementation, even if the user can still complete the flow.

This is where most guides recommend stable, user-facing locators. That's really useful advice. But we can take it even further: define tests based on the user's intention instead.

Users don't know what a test ID is. They don't even see it!

We'll use last month's report and set up matching test data for each run. That keeps the test suite useful as the months change.

With [`agent.act`](https://e2e.com/docs/core-concepts#goals), you describe a goal and let the agent work out the actions:

```ts
await agent.act("Generate last month's activity report");
```

You really can't make it simpler than that. The agent can work out whether it needs to select a month, enter two dates, or choose a preset. And it couldn't care less whether you're using shadcn/ui, Bootstrap, or Material UI. Neither do your users.

But generating the report is only half the test. You need assertions that prove it was generated and that its contents reflect the correct data. An empty report is a bug and so is a report containing wrongs data.

```ts
await agent.assert(`The report shows ${expectedTasks.length} completed tasks`);
await agent.assert(
  `The report contains exactly these task records, with no additional tasks: ${JSON.stringify(expectedTasks)}`,
);
```

Now we're comparing the report with actual activity. An empty report fails when the source has tasks. A matching count with the wrong records fails too, including tasks belonging to another workspace. Read the expected data from the underlying activity, independently of the report you're testing.

For agentic tests to work, you need to keep the goal specific. A goal like "Test reports" leaves too much room for interpretation. Generating a particular report gives you a concrete outcome to check.

## Reduce flaky tests by waiting for outcomes

Adding a 5 second sleep is an easy way to make a flaky test pass on your machine. It's also an easy way to make every successful run five seconds slower. And those waits add up quickly.

Report generation is a good example. Depending on the amount of data and how busy the server is, the same operation might take a different amount of time on every run.

Instead of putting an arbitrary delay here, you can wait for the result:

```ts
await agent.waitFor("Last month's activity report is ready to view", { timeout: 15_000 });
```

[`agent.waitFor`](https://e2e.com/docs/reference/agent#agent-waitfor) checks the condition and waits for it to become true, within the timeout and model-call budget. The timeout is a ceiling. The test moves on soon after the condition holds instead of waiting out the full timeout.

Good practice here is to pick a condition that means the next step is ready. Waiting for a loading spinner to disappear might "work," but it isn't a great signal. Checking that the report is available gives you a much stronger one.

One API detail matters here: `agent.assert` checks the current screen immediately, without waiting. Use `agent.waitFor` when the app needs time to reach the state you want to check.

You can also use [`expect.poll`](https://e2e.com/docs/reference/expect#polling-a-value) to wait for a value. Compared to `agent.waitFor` which uses AI to check whether the screen matches your description, `expect.poll` keeps trying deterministically until the matcher passes or times out.

## Make your assertions specific

Clicking "Generate" is an action. Getting a report with the data is an outcome. Pretty simple.

A test that only performs actions leaves the most useful part unchecked. We need some state to assert.

Use the count from the API response or some other source of truth as the expected value. Once the report is open, we can assert that result:

```ts
await agent.assert(
  `The report covers last month and shows ${expectedTasks.length} completed tasks`,
);
```

You need to be precise here. Asserting something like "The report looks correct" makes the agent guess what you meant by correct.

With [`agent.assert`](https://e2e.com/docs/reference/agent#agent-assert), the judge evaluates fresh evidence from the current screen. A false or inconclusive judgment fails the test. The acting agent saying it completed the task is not enough.

Think about how much your assertion proves, too. A "Report ready" message tells you the app reached that status. It doesn't prove the report contains the right information. And checking the preview doesn't prove a downloaded CSV has the same contents.

For each meaningful action, identify the observable result that would convince you it worked. Then put that check in the test.

## Keep tests focused and independent

A report test shouldn't need the "complete a task" test to run first.

That dependency might work while you run the whole suite in a particular order. [Run the report test on its own](https://e2e.com/docs/core-concepts#run-one-test), retry it, or run tests in parallel, and suddenly the data disappears.

Each test should arrange the state it needs. For our report, that means a workspace with known tasks and completion dates. Another test completing a task in the same workspace shouldn't change the data between our API read and report generation.

Good test data management also means cleaning up after yourself. Use [`afterEach`](https://e2e.com/docs/reference/test#aftereach) to clean up after each test, or [`afterAll`](https://e2e.com/docs/reference/test#afterall) for data that lives across a whole suite. What goes in that hook depends on what your test did. Created a workspace through the API? Delete it through the API. Inserted records directly into the database? Remove those records. The next test should get the state it expects, without leftovers from a previous run.

e2e already provides fixtures such as `app`, `agent`, and `screen`. You can create your own fixtures with [`test.extend`](https://e2e.com/docs/fixtures), for example a workspace already populated with report data, and reuse it across your test suites.

Sharing code still helps when it keeps important behavior consistent. Use it where it makes tests easier to understand and maintain.

## Make failures easy to debug

Finding a bug is useful. Getting a red CI job with "something went wrong" is mostly an invitation to spend your afternoon investigating.

Start with a test name that describes the expected behavior. "Monthly activity report includes the workspace's completed tasks" gives you much more context than "reports test." When it fails, the name should already tell you which behavior broke.

Personally, I'd split generating a report, downloading it, and emailing it to a teammate into separate tests, each with its own setup and a specific name. Within each test, [keep agent goals small enough to check separately](https://e2e.com/docs/core-concepts#goals) so the debug log is easy to follow.

The debug log should tell you what failed and why: what the agent tried to do, what it expected, and what happened instead. A failed agent assertion can help here. For example, it might explain that the report shows the current month even though the test requested the previous one.

e2e's [failure traces](https://e2e.com/docs/debugging) add the steps leading to the failure, app logs, recent agent turns, and a screenshot and screen snapshot when available. That gives you, and [your coding agent](https://e2e.com/docs/coding-agents#how-an-agent-reads-a-failure), evidence to reason about.

It doesn't guarantee a perfect reproduction or identify the faulty line of application code. But a clear goal, an explicit expectation, and the observed result make a much better starting point.

Pick one flow you currently test by hand and write it this way. When it fails, you should be able to tell what the user was trying to do, what happened instead, and where to start looking.

## An end-to-end testing example with e2e

If you've written tests before, you might recognize the Arrange, Act, Assert (AAA) pattern. Set up the state you need, perform an action, and check the result. Agentic tests follow the same structure. You still decide where the test starts and what counts as success. The agent works out how to carry out the action.

Here's the whole monthly report test with all 5 tips applied:

```ts
test("Monthly activity report includes the workspace's completed tasks", async ({
  app,
  agent,
  reportWorkspace,
}) => {
  // Arrange
  const expectedTasks = await getCompletedTasks(reportWorkspace.id, "last month");
  await app.open(`/workspaces/${reportWorkspace.id}/reports`);

  // Act
  await agent.act("Generate last month's activity report");
  await agent.waitFor("Last month's activity report is open", { timeout: 15_000 });

  // Assert
  await agent.assert(
    `The report covers last month and shows ${expectedTasks.length} completed tasks`,
  );
  await agent.assert(
    `The report contains exactly these task records, with no additional tasks: ${JSON.stringify(expectedTasks)}`,
  );
});
```

Here, `reportWorkspace` is a [fixture](https://e2e.com/docs/fixtures). `getCompletedTasks` reads the current workspace's activity through the app's API. The agent generates the report, `waitFor` waits for it to open, and the assertions compare its contents with that source of truth.

## Where to go from here

A test suite earns your trust one useful result at a time. Describe what the user wants to do. Wait for the app to be ready. Check that the result is actually what you expected. Agentic testing gives you more room to focus on those decisions, but you still need to make them.

Start with one test your team keeps rerunning. Look at what it's trying to prove, what it's waiting for, and what would make it fail. Fix that one, then move to the next. You don't need to rewrite the whole suite to start making it useful again.

Want to try this in your own app? Head to the [e2e quickstart](https://e2e.com/docs/quickstart), get it running, and pick one flow to test. Then break something on purpose and see if your test catches it!
