Testing makes me a confident developer

Main Thread 5 min read

The truth is I don't always test my code. I take on the risk of bugs. It's a gamble. I don't want to spend the time testing code early on.

But this gamble never pays off. It's only a matter of time until something in the code breaks.

Whether this is an actual bug found by users or I find myself it's embarrassing.

Without tests, I also introduce bugs. This happens enough times and eventually I become afraid to change the code. I code scared.

It's inevitable. At some point all code becomes foreign. That point when the code no longer fits in your brain. You no longer remember the finer bits of detail.

After that point, your outlook changes. You curse the code. You think, "WTF is this?" even if you were the original author. All this builds resentment and you start to justify rewriting the code.

We jump to a rewrite over a refactor. Often because a rewrite gives us the chance to know the code again. Which in turn gives us confidence and ultimately we continue to avoid writing tests.

But this is a false confidence. It's confidence in our memory, not the code. And it doesn't last.

Testing is the only way I really feel confident about the code. Testing gives me confidence not only that the code does what I expect now, but confidence the code will continue to do so in the future.

Again I don't test everything. But if I have an application which crosses a demand threshold, I at least test the critical parts.

Gaining confidence with tests

I want to take a look at existing piece of code without test.

I'll start by writing tests to gain confidence the code behaves as expected. Then I'll use that confidence to refactor the code.

Let's take a look some real world code from Shift. This controller action provides the ability to rerun a previously failed Shift.

1public function rerun(RerunShiftRequest $request, Order $order)
3 $this->verifyOrderBelongsToUser($order);
5 if (!$order->canRerun()) {
6 return redirect()->back()->with('error', ['template' => 'partials.errors.can_not_rerun', 'data' => ['order_id' => $order->id]]);
7 }
9 $repository = Repository::createFromName($request->input('repository'));
10 $connection = Connection::findOrFail($request->input('connection_id'));
12 try {
13 if ($connection->isGitLab()) {
14 GitLabClient::addCollaborator($connection->access_token, $repository);
15 }
16 } catch (\Gitlab\Exception\RuntimeException $exception) {
17 Log::error(Connection::GITLAB . ' failed to connect to: ' . $request->input('repository') . ' with code: ' . $exception->getCode());
18 return redirect()->back()->withInput($request->input());
19 }
21 $order->update([
22 'connection_id' => $request->input('connection_id'),
23 'repository' => $request->input('repository'),
24 'source_branch' => $request->input('source_branch'),
25 'rerun_at' => now(),
26 ]);
28 PerformShift::dispatch($order);
30 return redirect()->to('/account');

While not a mission critical part of the application, it provides value by making the user self sufficient and prevents a support request.

This is about 30 lines of code and contains multiple paths.

First, I like to test the happy path. This is the path where the code behaves without error or exception.

In this case, it's where the appropriate data was passed in and the Shift was put back on the queue.

To get started, I'll create an HTTP test and write a test case which sends the proper data and ultimattely assert it was dispatched to the queue.

That test case becomes:

1/** @test */
2public function rerun_updates_order_and_adds_shift_queue()
4 $connection = factory(Connection::class)->create([
5 'service' => Connection::GITHUB
6 ]);
7 $order = factory(Order::class)->state('held')->create([
8 'connection_id' => $connection->id
9 ]);
11 $now = Carbon::parse($this->faker->dateTime);
12 $repository = 'shift/test-repository';
13 $branch = $this->faker->word;
15 $queue = Queue::fake();
16 Carbon::setTestNow($now);
19 $response = $this->actingAs($order->user)->post(route('order.rerun', $order), [
20 'connection_id' => $connection->id,
21 'repository' => $repository,
22 'source_branch' => $branch,
23 ]);
26 $response->assertRedirect('/account');
28 $queue->assertPushed(PerformShift::class, function (PerformShift $job) use ($order) {
29 return $job->order->is($order);
30 });
32 $order->refresh();
34 $this->assertEquals($connection->id, $order->connection_id);
35 $this->assertEquals($repository, $order->repository);
36 $this->assertEquals($branch, $order->source_branch);
37 $this->assertEquals($now, $order->rerun_at);

Next I want to test the additional paths.

There are a few exceptional, or sad paths. The one I want to focus on is when the GitLabClientException is thrown.

When this exception is thrown, an error response is returned which redirects back to the rerun form with the appropriate data.

That test case becomes:

1/** @test */
2public function rerun_redirect_and_does_not_rerun_for_a_non_rerunnable_shift()
4 $order = factory(Order::class)->state('ran_twice')->create();
6 $repository = 'shift/test-repository';
7 $branch = $this->faker->word;
9 $queue = Queue::fake();
12 $response = $this->from('/rerun')->actingAs($order->user)->post(route('order.rerun', $order), [
13 'connection_id' => $order->connection_id,
14 'repository' => $repository,
15 'source_branch' => $branch,
16 ]);
19 $response->assertRedirect('/rerun');
20 $response->assertSessionHas('error', [
21 'template' => 'partials.errors.can_not_rerun',
22 'data' => ['order_id' => $order->id]
23 ]
24 );
26 $queue->assertNothingPushed();

There's additional sad path for when:

  1. Invalid data was sent.
  2. The user doesn't own the Shift.
  3. The Shift is not eligible for a rerun.

I'm not going to worry so much about the incorrect data being sent.

Validation can take a while to test properly and doesn't provide much additional confidence. Often, I'll use an alternative approach for testing validation in Laravel.

I also won't focus on the user not owning the Shift. While this is a small security measure, I'll save that for another time.

The path which is custom to this feature is eligibility to rerun logic. So I want to test this.

In order to do so I will manipulate the Order data to met the condition for canRerun.

This test case becomes:

1/** @test */
2public function rerun_does_not_rerun_and_redirects_back_when_there_is_a_gitlab_client_exception()
4 $connection = factory(Connection::class)->create([
5 'service' => Connection::GITLAB
6 ]);
7 $order = factory(Order::class)->state('held')->create([
8 'connection_id' => $connection->id
9 ]);
11 $repository = 'shift/test-repository';
12 $branch = $this->faker->word;
14 $gitlab_client = $this->mock(GitLabClient::class);
15 $gitlab_client->shouldReceive('addCollaborator')
16 ->with($connection->access_token, Mockery::type(Repository::class))
17 ->andThrow(GitLabClientException::class);
19 $queue = Queue::fake();
22 $response = $this->from('/rerun')->actingAs($order->user)->post(route('order.rerun', $order), [
23 'connection_id' => $connection->id,
24 'repository' => $repository,
25 'source_branch' => $branch,
26 ]);
29 $response->assertRedirect('/rerun');
30 $response->assertSessionHas('_old_input', [
31 'connection_id' => $connection->id,
32 'repository' => $repository,
33 'source_branch' => $branch,
34 ]);
36 $queue->assertNothingPushed();

Refactoring with tests

Now I have tests which give me confidences this code is behaving as expected.

Armed with the confidence of these tests, I now have the ability to truly refactor the existing code.

First, I don't like the try/catch block. I've never been an exceptional programmer 😅. try/catch blocks appear dense to me. Furthermore, Laravel offers a better way.

Starting in Laravel 5.5, the framework will automatically call the render() or report() method defined on a custom exception.

As such, I can move the code for generating the response to the GitLabClientException and allow the framework to catch the exception and return the response.

1namespace App\Exceptions;
3use App\Models\Connection;
4use Illuminate\Support\Facades\Log;
6class GitLabClientException extends \Exception
8 public function render($request)
9 {
10 Log::error(Connection::GITLAB . ' failed to connect to: ' . $request->input('repository') . ' with code: ' . $this->getCode());
11 return redirect()->back()->withInput($request->input());
12 }

Now with the framework handling the exception, I have not only reduced the lines of code, but made it less complex and more readable.

1- try {
2 if ($connection->isGitLab()) {
3 resolve(GitLabClient::class)->addCollaborator($connection->access_token, $repository);
4 }
5- } catch (GitLabClientException $exception) {
6- Log::error(Connection::GITLAB . ' failed to connect to: ' . $request->input('repository') . ' with code: ' . $exception->getCode());
7- return redirect()->back()->withInput($request->input());
8- }

To confirm everything behaves as expected, I simply run the tests to see everything passes.

Passing PHPUnit test results

In 272 milliseconds, tests afford me the confidence I have successfully refactored the code. Something I might have been reluctant to do without tests, or introduced bugs during the refactor.

Truly Refactoring

Refactoring is defined by Martin Fowler as:

a change made to the internal structure of software to make it easier to understand and cheaper to modify without changing its observable behavior.

Many developers set out to refactor code, but really change code.

Having tests help verify the behavior was not changed. This in turn provides confidence you are truly refactoring the code.

I don't want to do anything to lose this confidence.

As such when refactoring or adding tests, I make it a point to only alter one set of code at a time.

When starting to test an existing implementation I only wrote code for the tests. Once I has the tests, I only altered the implementation code then confirmed it with the tests.

Together the tests and the code create a balance. The tests confirm the code and the code confirms the tests. It's a harmonious equation, where only one side should be changed at a time.

Need to test your Laravel applications? Check out the Tests Generator Shift to quickly generate tests for an existing codebase and the Confident Laravel video course for a step-by-step guide from no tests to a confidently tested Laravel application.