Logan Bailey

Adventures In Web Development

Blog, About, GitHub, and LinkedIn

I've always written my tests iteratively. Starting at the top of a function, I'd write a test for most if not all of the code flows. In the example below, I'd start on the first if branch and work my way down to the return

<?php

class PostRepository
{
	public function createPost(User $author, string $title, string $body): Post
	{
		if (/* Validate title too long */) {
			throw new InvalidArgumentException();
		}

		if (/* Validate title too short */) {
			throw new InvalidArgumentException();
		}

		if (/* Validate title contains html */) {
			throw new InvalidArgumentException();
		}

		if (/* Validate body too short */) {
			throw new InvalidArgumentException();
		}
		
		/* and so on */

		return $post;
	}
}

Resulting in at least 4 tests:

  • testExceptionIsThrownWhenTitleIsTooShort
  • testExceptionIsThrownWhenTitleIsTooLong
  • testExceptionIsThrownWhenTitleContainsHtml
  • testExceptionIsThrownWhenBodyIsTooShort

While preparing for a talk at my local meetup, I decided to cover the phrase I'd often heard but never cared to use test your public api. I had previously been turned off from it because I felt that it was too vague, my public api. But I felt it was important to cover in my talk, so I defined the term for better or worse for myself, leaving nothing up to readers interpretation. Test the function's parameters, return types, and thrown exceptions. Or as I like to call it input output testing. Below is an accurate description of the public api for the previous example:

/**
 * @param User   $author The user account to associate with the post
 * @param string $title  The title of the post
 * @param string $body   The body of the post
 *
 * @throws InvalidArgumentException When title or body contains invalid data
 * @throws InvalidPermissionsException When author does not have permission to post
 *
 * @return Post a newly created post with the title, body, and author.
 */
public function createPost(User $author, string $title, string $body): Post;

Using this docblock, we can easily stub out our test methods:

  • testInvalidArgumentIsThrownWhenPostOrTitleAreInvalid
  • testInvalidPermissionsExceptionIsThrowWhenAuthorDoesNotHaveRequiredPermissions
  • testCreatedPostContainsTitleBodyAndAuthor

Rather than having one test for every code path, I have three tests based on related inputs conditions and their outputs. Data providers make it easy to cleanly test all of our different code flows, e.g.:

/**
 * @param string $title A potentially invalid title
 * @param string $body  A potentially invalid body
 *
 * @dataProvider invalidTitleOrBodyProvider
 *
 * @expectedException InvalidArgument
 *
 */
public function testInvalidArgumentIsThrownWhenPostOrTitleAreInvalid($title, $body) 
{
	$this->repo->createPost($this->author, $title, $body);
}

public function invalidTitleOrBodyProvider() {
	return [
		'Title Too Short'     => ['a', 'A valid body'],
		'Title Too Long'      => ['aaaaaa....aaa', 'A valid body'],
		'Title contains html' => ['<h1>Hello</h1>', 'a valid body'],
		'Body Too Short'      => ['A Valid Title', 'asd']
	];
}

Here, I was able to write a test with out seeing any of implementation code, only knowing the business logic. TDD no longer seemed so complicated. More importantly, I had added flexibility. Earlier I had demonstrated createPost using inline validation, in the future I may wish to use a validation library. Since I changed how I approach writing tests, It's easy to update my one test method to use a mock validation library.

/**
 * @expectedException InvalidArgument
 *
 */
public function testInvalidArgumentIsThrownWhenPostOrTitleAreInvalid($title, $body) 
{
	$title = 'my title';
	$body = 'my body';

	$validator = $this->createMock(PostCreateValidator::class);
	$validator->expects($this->once())
		->method('validate')
		->with($title, $body)
		->willThrow(new InvalidArgumentException);

	$repo = new PostRepository($validator);
	$this->repo->createPost($this->author, $title, $body);
}

My tests can now grow with my code, rather than be refactored after my code has changed. This method of writing tests has lead me to create cleaner and more maintainable tests.

Posted In:
php phpunit testing