My Laws of Test Driven Development

Edit this page | 6 minute read

About 14 years ago, I got inspired by a series of posts written by (Alt) .NET veteran Jeremy D. Miller]. Now, with many years of both good and bad experiences practicing Test Driven Development, I thought it is the time to honor Jeremy by capturing my own “laws”. It goes without saying that using the term law is an exaggeration and principles or heuristics cover my intend much better.

Nonetheless, these are the “things” that have helped me avoid shooting myself in my own foot.

  1. Don’t practice Test Driven Development according to the books. Sure it’s a great technique to design testable software and control your dependencies. Yes, it forces you to think about the behavior of the subject-under-test. And indeed, it can lead to tests-as-documentation of your domain. This does not mean, however, that you should always start with a test. Especially when you’re designing new code, often, you first need to sketch out the class responsibilities, flesh out the details and then refactor the code so you can continue with a test-first approach.

  2. Don’t bother trying to agree on an exact definition of what the difference is between unit or integration tests. Just accept that you need tests on different levels (e.g. class, component, module, API, UI) that are complimentary. Arguing about which one is better is a non-sensical waste of time.

  3. Choose the right scope for your tests. I’ll dedicate a separate post on this, but suffice to say that a test-per-class is almost always the wrong approach. Look at which classes are supposed to work together, which are the ones used directly by other parts of the system, and which are just implementation details. This particularly works well if you organize your code in functional folders.

  4. Start with a functional name that describes what scenario the test case is supposed to validate. Most definitely don’t include the literal names of classes, types, methods or even the names of buttons in the UI. This helps the next developer to judge whether the test implementation actually does what it is supposed to do when it fails.

  5. Use naming conventions like When_something_happens_it_should_do_something, Should_do_something_when_something_happens or, even more concise Do_something_when_something_happens, but be consistent within a code base. Although JEST and Jasmine tests need a slightly different structure, the point of being functional remains.

  6. Strive for a crystal-clear cause and effect, where it’s completely clear what the begin state is, what the action or subject-under-test is, and what you are asserting in this specific test. In other words, if you need to browse through all the helper methods or base-classes to understand the test, there’s definitely room for improvement.

  7. Only have one scenario per test. So doing something, asserting the outcome, then doing something else and then again asserting the outcome should be avoided. It makes the test harder to read, and will make it skip the second or third scenario if the first one fails.

  8. Treat test code as first-class citizens of your code-base. In other words, everything that applies to your production code, also applies to your tests. Review them, refactor them, and evolve them, just like all the other code.

  9. But be cautious with DRY (Don’t Repeat Yourself) such as introducing base-classes for your tests or fixtures like the Shared Context offered by xUnit. It usually leads to hiding the important parts of the test case or too much intelligence in those base-classes or fixtures, resulting in the need to add virtual members so test cases can override that logic.

  10. Hide the set-up code that is not important, but show what’s relevant for that specific test. Why am I saying this? Because each test case is supposed to assert something else and should fail for one reason only. Use Object Mothers or Test Data Builders to encapsulate the logic for building valid objects.

  11. As a consequence of the above, only assert what’s relevant. For example, use wildcards to check only the part of the exception message that you need to assert that the right exception was thrown. And when verifying objects in your tests, only assert on the properties or fields you care about in that specific test, for example, by comparing them with an anonymous type.

  12. Use inline literal strings and numbers instead of defining constants at the top of your test. Usually we want to do the opposite to avoid magic numbers, but in tests, having those constants often make the cause and effect harder to understand.

  13. Use a test framework that will run your tests in parallel to help you surface concurrency issues and race conditions as early as possible.

  14. Only test against the observable behavior of a class, module or component. In other words, avoid making “private” members “public”, or writing directly to the database from your test. If you really need to access “protected” members, consider using something like a test-specific subclass. But if you can, interact with the subject-under-test in exactly the same way as you would do in the production code.

  15. Postfix your test classes with Specs. Doing this emphasizes the idea of writing tests as a specification of the API surface and the behavior of the subject-under-test.

  16. Avoid overuse of mocking. If your tests rely a lot on mocking libraries, or worse, use multiple mocks in your tests, chances are that you are either testing too small or your code-under-test has too many responsibilities. And never mock the subject-under-test itself. Then you know for sure that there’s something wrong with the responsibilities of that class. Mocking code that encapsulates I/O code, timers or other things which are difficult to control in a test is totally fine though.

  17. When a test fails it should be clear what was expected and what actually happens. In other words, you should not need to run the test under a debugger attached to find out what went wrong. For example, when Assert.IsTrue(value == 5) fails, you don’t see why it failed. It just states it expected a condition to be true but found false. The use of a good assertion library (like FluentAssertions) can help. For instance, value.Should.Be(5) results in a clear error message that value was expected to be 5 but the actual value was 4. You can even include a rationale as a parameter.

  18. Acknowledge that some tests are more state-based and others more orchestrational. The former fits well with the Arrange-Act-Assert conventions like this:

     public void When_something_happens_it_should_do_something_else()
     {
         // Arrange
         ...setup the subject-under-tests and/or its dependencies
            
         // Act
         ...where we do the real work
            
         // Assert
         ...where we ensure the SUT has done what it should do
     }
    

    Orchestrational tests usually have multiple outcomes, so a more BDD-style fits much better. For example, using .NET’s Chill BDD library allow you to write them like this:

     public class When_retrieving_existing_customer : GivenSubject<CustomerController, View> 
     {
         const int customerId = 12;
            
         public When_retrieving_existing_customer()
         {
             Given(() => {
                 SetThe<Customer>().To(EntityMother.CreateACustomer().With(x => x.I = customerId));
                
                 The<ICustomerStore>().GetCustomer(customerId).Returns(The<Customer>());
             });
                
             When(() => Subject.Get(customerId));
         }
        
         [Fact]
         public void Then_view_is_returned()
         {
             Result.Should().NotBeNull();
         }
            
         [Fact]
         public void Then_model_is_the_existing_customer()
         {
             Result.Model.Should().Be(The<Customer>());
         }
     }
    

So what do you think? Which of these practices have you already been using? Did you learn anything new? And did I miss something? Let me know by commenting below. Oh, and follow me at @ddoomen to get regular updates on my everlasting quest for better solutions

Leave a Comment