Wednesday, 3 October 2007

Making unit tests

What approach should you take in setting up unit tests?

  • A more pragmatic approach where tests are more API usage-oriented, how you expected other parts of your application to interact with your unit of code.
  • Or should you strive for a formal way of working where systematically check every each method for all boundary conditions and error paths.
  • Or should you use both?
  • What approach in which conditions?
  • Should conduct a test-first approach, or test after approach? Or should you write your test during?

Take a look at this sample method:

'Method returns true if the person is over age 18, otherwise false
Public Function AgeCheck(dob as Date) as Boolean
'Some processing code here…
End Function


The premise of this code is fairly straightforward. It accepts a date of birth and validates the age. At first glance, you may think that you simply need to run a test that provides a date of birth prior to 18 years ago and a date of birth later than 18 years ago. And that works great for 80% of the dates that someone is likely to provide, but that only scratches the surface of what could be tested here. The following are some of the tests you may also consider to run for the AgeCheck method:

• Check a date from exactly 18 years ago to the day.
• Check a date from further back in time than 18 years.
• Check a date from fewer than 18 years ago.
• Check for the results based on the very minimum date that can be entered.
• Check for the results based on the very maximum date that can be entered.
• Check for an invalid date format being passed to the method.
• Check two year dates (such as 05/05/49) to determine what century the two-year format is being treated as.
• Check the time (if that matters) of the date.

As you can see, based on just a simple method declaration, you can determine at least 5 and maybe as many as 10 valid unit tests (not all tests may be required).


These following areas cover the majority of the situations you will run into when performing unit tests. Any approach should take into account tests that cover these areas of potential failure. Of course, this is by no means a comprehensive set of rules for unit testing. There will undoubtedly be others related to specific applications.

Boundary values

Testing the minimum and maximum values allowed for a given parameter. An example here is when dealing with strings. What happens if you are passed an empty string? Is it valid? On the other hand, what if someone passes a string value of 100,000 characters and you try to convert the length of the string to a short?

Equality

Test a value that you are looking for. In the case of the age check example used here, what if the date passed also includes the time? An equality check will always fail in that case, but in every other case, the results would come out correctly.

Format

Never trust data passed to your application. This is particularly applicable to any boundary methods, where you can get malicious hackers looking to crack your system. But poorly formatted data from any source can cause problems if you do not check it.

Culture

Various tests need to be performed to ensure that cultural information is taken into account. In general, you can skip these tests if the application is written for a single culture only (although, even this is not a good indicator, because you can write a test for a U.S. English system but have it installed by someone in the U.K.). Otherwise, string, date, currency, and other types of values that can be localized should always be checked (for example, if you are expecting a date in the U.S. format but get it in the European format, then some values will work, but some will not). An example is the date 02/01/2000. In the U.S. format, this is February 1, 2000; in the European format, it is January 2, 2000. While this test would pass (most of the time), changing the U.S. format to 01/23/2000 would fail in the European format.

Exception paths

Make sure that the application handles exceptions—both expected and unexpected—in the correct manner and in a secure fashion. Frequently, developers test the normal path and forget to test the exception path. VSTS and NUnit allow you to annotate test methods with expectedException. So while testing the exception paths, you expect that an exception will be thrown. So in that case the test is succeeded.

Coverage

Not everything needs to/can be tested. In most cases, you want to test the most used paths of the application for the most common scenarios. An example of wasted time would be writing tests for simple property assignments. While this may be needed in specific situations, you cannot possibly write every test for every scenario—it takes too long even with automated tools!

Test both expected behavior (normal workflows) and error conditions (exceptions and invalid operations). This often means that you will have multiple unit tests for the same method, but remember that developers will always find ways to call your objects that you did not intend. Expect the unexpected, code defensively, and test to ensure that your code reacts appropriately. Don’t write only “clean” test. Make sure your code responds to any and all scenarios, including when the unexpected happens.

In order for you to measure the effectiveness of your tests, you need to know what parts of your code are tested by unit test. Code coverage is the concept of observing which lines of code are used during execution of unit tests. You can easily identify regions of your code that are not being executed and create tests to verify that code. This will improve your ability to find problems before the users of your system do.

But that's for another post.

No comments: