Work With Us

How to Write Automated Tests: A Step-by-Step Guide

by Ellie Roberts
10 min read

In part one we explained what automated testing is and why we use it. In part two, we teach you how to write your own automated tests to help you increase your testing efficiency.

The hardest part of writing tests is getting started. In perfect, small site scenarios and documentation examples, testing can be straightforward but that is not always the case when working on a commercial site. Each site (and the company it belongs to) is individual and has specific business requirements for what they need from a system.

An e-commerce site selling shoes has very different requirements than a pricing system, and a personal blog has very different requirements from either of the first two examples. These different requirements should be reflected in the focus of the test suite, but no matter what is being tested the steps for writing tests are the same.

Please note: examples in this blog post are for Laravel framework with PHPUnit for testing.

Prep: Determine Requirements

When going to write the first test for a section of code, it can be difficult to know what should be the context for the first test. How the code runs may depend on the presence of models, relationships, data, the authenticated user, the parameters passed, the configuration of the site, and many other variables.

Arguably the most important test aims to assert that the code will run in the most likely scenario, and might be the first you write. This should test that your code works when all of the parameters and models are available as expected, for example;

Sometimes it can be difficult to determine the most likely scenario. In those cases, starting with the simplest test can also be a good starting point. Instead of replicating the most likely scenario that the code will be run in, provide the minimum amount of required models and data for the test.

Step One: Setup necessary models, mocks and data

Step one of testing is often the largest part, and involves creating whatever is required for the code that is being evaluated to run. Some tests will require very little or no setup, for example a static page with no authentication.

Setup can include: authenticating as a user, creating models, instantiating classes, adding fake data, and mocking repositories or external dependencies. It also includes setting up expected exceptions (but that will be covered in a later section).

Authentication

Starting on the simpler end, there might be a homepage with a welcome banner and some content directing the user to other parts of the site. A lot of homepages do not have dynamic content depending on the user, and authentication is not required if the page is publicly accessible. In this example, no setup for authentication, models or mocks are required to test the homepage.

A slightly more complicated test might be for an account page which displays past orders. The page is behind authentication, so the first step in the setup is to create a user and authenticate as that user.

$user = new User();
$this->actingAs($user);


Models & Data

For past orders to be seen the user must have some records of orders, so the next step might be to create an order model associated with the authenticated user.

$order = new Order(
	id: $user->id,
	user_id: 1,
	note: 'Order for 25-01-25',
	created_at: '2025-02-12 11:47:16',
	updated_at: '2025-02-12 11:47:16',
	items: [
		[
			'product_code' => 'TEST123',
			'quantity' => 3,
		],
	],
]);

Feature testing in Laravel will often use factories to reduce the amount of hard-coded values necessary for tests. A factory will create one or more instances of a model with generated fake data. Which fields are generated by default is determined by what the model requires, and is configured in the factory class.

In the below example, the Order model could have the same fields as the previous example, but the ID, notes and timestamp fields are generated automatically by the factory instead of defined in the test.

$order = Order::factory()->create([
	'user_id' => $user->id,
	'items' => [
		[
			'product_code' => 'TEST123',
			'quantity' => 3,
		],
	],
]);

Tests will often also require data other than models, such string values (text), arrays, or other classes. These are set up as they would be in non-test code.

$myString = "Here is some text";
$myArray = [
    'required' => true,
];
$myClass = new MyClass();

Mocking dependencies

Mocking is the replacement of part of the code to allow for testing of other parts of the system. The mock replaces a class so that the code inside doesn’t run, and the mock can instead be used to set up expectations for how the replaced class should be interacted with. 

Learning how to mock is one of the biggest challenges to writing tests for more complex code, and trying to cover the topic in this post will not be possible. It will be briefly covered in this section, and will be the focus of a future testing blog post to expand on the use cases.

Consider a situation where the orders are held in an external API, and external APIs should not be called in test code. If this were the case, in the setup you would want to include a mock of the code responsible for calling the external API to prevent any requests from being made.

Mocking can be difficult as it can depend on the code being set up with a thin “external layer” class. This means that there needs to be a class responsible only for communicating with the external service, and containing none of the logic or functionality that requires testing. This class is then mocked, allowing any code which depends on the mocked class to be run and only replacing the code which is responsible for communicating with the external service directly.

Mocking can also be used to isolate the code you are testing from other internal code. This can be useful when writing a test for a specific area of your code which has internal dependencies which are tested separately.

$this->mock(OrdersService::class, function (Mockery $mock) {
$mock->shouldReceive('place')->once()->andReturnTrue();
});

Tests might also require other forms of data, such as files or items in the cache. These can be set up manually (as it would be in non-test code), but for more robust tests mocking the caching/file handling functionality is the most ideal solution.

Step Two: Call the code you are testing

The second step of writing tests is often the simplest; call the code that the test is making assertions about.

Example One

$formatter = new StringFormatter();
$result = $formatter->format($value);

Example Two

$response = $this->get('/');

Example Three

$response = $this->post('/products', [
 'search_term' => 'Striped'
 'category' => 'tshirts',
 'colour' => 'red',
]);
$response->assertRedirect('/product-results');
$redirectResponse = $response->followRedirects();

Step Three: Check the results, and make assertions

Once the dependencies have been set up, and the code being tested has been called, the final step of testing is to make checks which determine if the requirements are being fulfilled. This is an important step because the assertions must be centred around an aspect of the code which will be different in success and failure circumstances.

To decide what should be checked at the end of the test, the requirements for the code can guide the way. If the test is for a method, what is the purpose of the method and what does success look like in comparison with failure?

$response = $this->assertOk($response);
$response->assertRedirect('/results');

When a form is submitted, the user might be redirected in both successful and unsuccessful situations. In this case, asserting that the response is a redirect will mean that in failure circumstances this test incorrectly passes. Checking for success or failure flash messages will make tests less likely to be flaky or return false positive results.

$this->assertStringContainsString($successMsg, $response->getContent());

Happy paths

Similarly to manual testing, it is easier to consider and write tests for “happy paths”. Happy paths are the intended user flow, navigating through the site with no errors or issues (consider “how the site is meant to be used”). Testing happy paths is where all testing begins, and is what gives the reassurance that the code you are testing is not functionally broken.

Unhappy paths and edge-use cases

Equally important to test are the “unhappy paths” and edge use cases. Unhappy paths include issues and exceptions that a user might encounter when navigating through the site, including failed validation, not found errors, not authorized exceptions or bad request exceptions. Edge use cases are the less used paths that the user flow might follow; using a back button or cancelling out of modal early. Failure tests are incredibly useful for ensuring that expected errors are thrown gracefully, and to ensure that bad actors are unable to abuse the system.

Not Found Example

$response = $this->get('/this-page-does-not-exist');
$response->assertNotFound();

Bad Request Example

$response = $this->post('/register', ['hajshfjkhaksjh' => ['Malformed request']]);
$response->assertBadRequest();

Expected Exception Example

$this->expectException(DivisionByZeroException::class);
$calculator = new Calculator();
$calculator->divide(12, 0); 

Final Thoughts

Getting started with tests can be hard, but it is easier when comprehending individual steps of the process and testing provides significant benefits (see our previous blog post on why we use automated testing). 

Written by Ellie Roberts
Software Engineer