Let's TDD a Simple App in PHP

In this tutorial, I will present an end-to-end example of a simple application - made strictly with TDD in PHP. I will walk you through each step, one at a time, while explaining the decisions I made in order to get the task done. The example closely follows the rules of TDD: write tests, write code, refactor.


Step 1 - Introduction to TDD & PHPUnit

Test Driven Development (TDD)

TDD is a "test-first" technique to develop and design software. It is almost always used in agile teams, being one of the core tools of agile software development. TDD was first defined and introduced to the professional community by Kent Beck in 2002. Since then, it has become an accepted - and recommended - technique in everyday programming.

TDD has three core rules:

  1. You are not allowed to write any production code, if there is not a failing test to warrant it.
  2. You are not allowed to write more of a unit test than is strictly necessary to make it fail. Not compiling / running is failing.
  3. You are not allowed to write more production code than is strictly necessary to make the failing test pass.

PHPUnit

PHPUnit is the tool that allows PHP programmers to perform unit testing, and practice test-driven development. It is a complete unit testing framework with mocking support. Even though there are a few alternative choices, PHPUnit is the most used and most complete solution for PHP today.

To install PHPUnit, you can either follow along with the previous tutorial in our "TDD in PHP" session, or you can use PEAR, as explained in the official documentation:

  • become root or use sudo
  • make sure you have the latest PEAR: pear upgrade PEAR
  • enable auto discovery: pear config-set auto_discover 1
  • install PHPUnit: pear install pear.phpunit.de/PHPUnit

More information and instructions for installing extra PHPUnit modules can be found in the official documentation.

Some Linux distributions offer phpunit as a precompiled package, though I always recommend an installation, via PEAR, because it ensures that the most recent and up-to-date version is installed and used.

NetBeans & PHPUnit

If you're a fan of NetBeans, you can configure it to work with PHPUnit by following these steps:

  • Go to NetBeans' configuration (Tools / Options)
  • Select PHP / Unit Testing
  • Check that the "PHPUnit Script" entry points to a valid PHPUnit executable. If it does not, NetBeans will tell you this, so if you don't see any red notices on the page, you are good to go. If not, look for the PHPUnit executable on your system and enter its path in the input field. For Linux systems, this path is typically /usr/bin/phpunit.

If you do not use an IDE with unit testing support, you can always run your test directly from the console:


Step 2 - The Problem to Solve

Our team is tasked with the implementation of a "word wrap" feature.

Let's assume that we are part of a large corporation, which has a sophisticated application to develop and maintain. Our team is tasked with the implementation of a "word wrap" feature. Our clients don't wish to see horizontal scroll bars, and it's out job to comply.

In that case, we need to create a class that is capable of formatting an arbitrary bit of text provided as input. The result should be word wrapped at a specified number of characters. The rules of word wrapping should follow the behavior of other every-day applications, like text editors, web page text areas, etc. Our client does not understand all the rules of word wrapping, but they know they want it, and they know it should work in the same way that they've experienced in other apps.


Step 3 - Planning

TDD helps you achieve a better design, but it does not eliminate the need for up-front design and thinking.

One of the things that many programmers forget, after they start TDD, is to think and plan beforehand. TDD helps you achieve a better design most of the time, with less code and verified functionality, but it does not eliminate the need for up-front design and human thinking.

Every time you need to solve a problem, you should set aside time to think about it, to imagine a little design - nothing fancy - but enough to get you started. This part of the job also helps you to imagine and guess possible scenarios for the logic of the application.

Let's think about the basic rules for a word wrap feature. I suppose some un-wrapped text will be given to us. We will know the number of characters per line and we will want it to be wrapped. So, the first thing that comes to my mind is that, if the text has more characters than the number on one line, we should add a new line instead of the last space character that is still on the line.

Okay, that would sum up the behavior of the system, but it is much too complicated for any test. For example, what about when a single word is longer than the number of characters allowed on a line? Hmmm... this looks like an edge case; we can't replace a space with a new line since we have no spaces on that line. We should force wrap the word, effectively splitting it into two.

These ideas should be clear enough to the point that we can start programming. We'll need a project and a class. Let's call it Wrapper.


Step 4 - Starting the Project and Creating the First Test

Let's create our project. There should be a main folder for source classes, and a Tests/ folder, naturally, for the tests.

The first file we will create is a test within the Tests folder. All our future test will be contained within this folder, so I will not specify it explicitly again in this tutorial. Name the test class something descriptive, but simple. WrapperTest will do for now; our first test looks something like this:

Remember! We are not allowed to write any production code before a failing test - not even a class declaration! That's why I wrote the first simple test above, called canCreateAWrapper. Some consider this step useless, but I consider it to be a nice opportunity to think about the class we are going to create. Do we need a class? What should we call it? Should it be static?

When you run the test above, you will receive a Fatal Error message, like the following:

Yikes! We should do something about it. Create an empty Wrapper class in the project's main folder.

That's it. If you run the test again, it passes. Congratulations on your first test!


Step 5 - The First Real Test

So we have our project set up and running; now we need to think about our first real test.

What would be the simplest...the dumbest...the most basic test that would make our current production code fail? Well, the first thing that comes to mind is "Give it a short enough word, and expect the result to be unchanged." This sounds doable; let's write the test.

That looks fairly complicated. What does "MaxChars" in the function name mean? What does 5 in the wrap method refer to?

I think something is not quite right here. Isn't there a simpler test that we can run? Yes, there certainly is! What if we wrap ... nothing - an empty string? That sounds good. Delete the complicated test above, and, instead, add our new, simpler one, shown below:

This is much better. The name of the test is easy to understand, we have no magic strings or numbers, and most of all, IT FAILS!

As you can observe, I deleted our very first test. It is useless to explicitly check if an object can be initialized, when other tests also need it. This is normal. With time, you will find that deleting tests is a common thing. Tests, especially unit tests, have to run fast - really fast... and frequently - very frequently. Considering this, eliminating redundancy in tests is important. Imagine that you run thousands of tests every time you save the project. It should take no more than a couple of minutes, maximum, for them to run. So, don't be terrified to delete a test, if necessary.

Getting back to our production code, let's make that test pass:

Above, we've added absolutely no more code than is necessary to make the test pass.


Step 6 - Pressing On

Now, for the next failing test:

Failure message:

And the code that makes it pass:

Wow! That was easy, wasn't it?

While we are in the green, observe that our test code can begin to rot. We need to refactor a few things. Remember: always refactor when your tests pass; this is the only way that you can be certain that you've refactored correctly.

First, let's remove the duplication of the initialization of the wrapper object. We can do this only once in the setUp() method, and use it for both tests.

The setup method will run before each new test.

Next, there are some ambiguous bits in the second test. What is 'word'? What is '5'? Let's make it clear so that the next programmer who reads these tests doesn't have to guess.

Never forget that your tests are also the most update-to-date documentation for your code.

Another programmer should be able to read the tests as easily as they would read the documentation.

Now, read this assertion again. Doesn't that read better? Of course it does. Don't be afraid of lengthy variable names for your tests; auto-completion is your friend! It's better to be as descriptive as possible.

Now, for the next failing test:

And the code that makes it pass:

That's the obvious code to make our last test pass. But be careful - it is also the code that makes our first test to not pass!

We have two options to fix this problem:

  • modify the code - make the second parameter optional
  • modify the first test - and make it call the code with a parameter

If you choose the first option, making the parameter optional, that would present a little problem with the current code. An optional parameter is also initialized with a default value. What could such a value be? Zero might sound logical, but it would imply writing code just to treat that special case. Setting a very large number, so that the first if statement would not result in true can be another solution. But, what is that number? Is it 10? Is it 10000? Is it 10000000? We can't really say.

Considering all these, I will just modify the first test:

Again, all green. We can now move on to the next test. Let's make sure that, if we have a very long word, it will wrap on several lines.

This obviously fails, because our actual production code wraps only once.

Can you smell the while loop coming? Well, think again. Is a while loop the simplest code that would make the test pass?

According to 'Transformation Priorities' (by Robert C. Martin), it is not. Recursion is always simpler than a loop and it is much more testable.

Can you even spot the change? It was a simple one. All we did was, instead of concatenating with the rest of the string, we concatenate with the return value of calling ourself with the rest of the string. Perfect!


Step 7 - Just Two Words

The next simplest test? What about two words can wrap, when there's a space at the end of the line.

That fits nicely. However, the solution may be getting a bit trickier this time.

At first, you might refer to a str_replace() to get rid of the space and insert a new line. Don't; that road leads to a dead end.

The second most obvious choice would be an if statement. Something like this:

However, that enters an endless loop, which will cause the tests to error out.

This time, we need to think! The problem is that our first test has a text with a length of zero. Also, strpos() returns false when it can't find the string. Comparing false with zero ... is? It is true. This is bad for us because the loop will became infinite. The solution? Let's change the first condition. Instead of searching for a space and comparing its position with the line's length, let's instead attempt to directly take the character at the position indicated by the line's length. We will do a substr() only one character long, starting at just the right spot in the text.

But, what if the space is not right at the end of line?

Hmm... we have to revise our conditions again. I am thinking that we will, after all, need that search for the position of the space character.

Wow! That actually works. We moved the first condition inside the second one so that we avoid the endless loop, and we added the search for space. Still, it looks rather ugly. Nested conditions? Yuck. It's time for some refactoring.

That's better better.


Step 8 - What About Multiple Words?

Nothing bad can happen as a result of writing a test.

The next simplest test would be to have three words wrapping on three lines. But that test passes. Should you write a test when you know it will pass? Most of the time, no. But, if you have doubts, or you can imagine obvious changes to the code that would make the new test fail and the others pass, then write it! Nothing bad can happen as a result of writing a test. Also, consider that your tests are your documentation. If your test represents an essential part of your logic, then write it!

Further, the fact the tests we came up with are passing is an indication that we are getting close to a solution. Obviously, when you have a working algorithm, any test that we write will pass.

Now - three words on two lines with the line ending inside the last word; now, that fails.

I nearly expected this one to work. When we investigate the error, we get:

Yep. We should wrap at the rightmost space in a line.

Simply replace the strpos() with strrpos() inside the second if statement.


Step 9 - Other Failing Tests? Edge Cases?

Things are getting trickier. It's fairly hard to find a failing test ... or any test, for that matter, that was not yet written.

This is an indication that we are quite close to a final solution. But, hey, I just thought of a test that will fail!

But, I was wrong. It passes. Hmm... Are we done? Wait! What about this one?

It fails! Excellent. When the line has the same length as the word, we want the second line to not begin with a space.

There are several solutions. We could introduce another if statement to check for the starting space. That would fit in with the rest of the conditionals that we've created. But, isn't there a simpler solution? What if we just trim() the text?

There we go.


Step 10 - We Are Done

At this point, I can't invent any failing test to write. We must be done! We've now used TDD to build a simply, but useful, six-line algorithm.

A few words on stopping and "being done." If you use TDD, you force yourself to think about all sorts of situations. You then write tests for those situations, and, in the process, begin to understand the problem much better. Usually, this process results in an intimate knowledge of the algorithm. If you can't think of any other failing tests to write, does this mean that your algorithm is perfect? Not necessary, unless there is a predefined set of rules. TDD does not guarantee bug-less code; it merely helps you write better code that can be better understood and modified.

Even better, if you do discover a bug, it's that much easier to write a test that reproduces the bug. This way, you can ensure that the bug never occurs again - because you've tested for it!


Final Notes

You may argue that this process is not technically "TDD." And you're right! This example is closer to how many everyday programmers work. If you want a true "TDD as you mean it" example, please leave a comment below, and I'll plan to write one in the future.

Thanks for reading!

Tags:

Comments

Related Articles