I have been writing a ton of unit tests of late, and I am constantly asking myself the question "What are good unit tests?" There are probably as many nuanced versions of this as their are developers, but when you boil it down, it comes down to the following:

  1. They must be automatic, and just tell you PASS/FAIL.
  2. They must be thorough. This means that ideally, you cover each branch of code, each case, and each error path.
  3. They must be repeatable/reliable, and produce the same results each time, be it today, tomorrow or next  year.
  4. They must be independent. This means that they should not rely on other services being up, or on other tests having been run. In addition, they should test only one thing.
  5. They must be "professional", following the same standards and care as is used in your production code (DRY, readable, etc.)
  6. They should run fast (~0.5sec/test ideally).

Now, some of these may be difficult, or seem to fight against one another. For example, one might argue that independence and the need to test only one thing argues that you should mock all the calls made by the method, but pushing back against that happens to be the reliable, since if you change an underlying method so that it breaks another in production, the test for the latter are brittle and unreliable. Those tests tend to give you pigs and boulders which can fly on their own. Nor should your test care whether the public method being tested calls another method in the interests of DRY, or accesses the properties directly. The only concerns are system calls (working from a PHP perspective) like date(), calls out to some API or other classes, and perhaps database calls.

Another guideline that should be followed is one which has the acronym AAA... Arrange, Act and Assert.  I have expanded this a bit to be "Arrange/Expect, Act, Assert", since some things like call or exception expectations must be setup during the arrange step, rather than being done after the call to the public method.  And if it helps, actually use comments in your test method to this effect.

One thing which is rather difficult is the idea of "One test, one assertion". The problem comes from the expectation and assertion statements are too limited in expressing the actual assertion goals on their own, and must be combined to fully express the overall assertion. For example, with a method under test which is supposed to return a particular class which is a collection of other classes, you might have half a dozen or more assertion statements, one to assert the collection class instance, and several more to assert that the items are instances of that other class. This leads to another issue in that in setting up the expectations and assertions for a test means that the test methods can get to be larger than the code being tested. Then there is the fact that to be thorough, you often end up several tests which are really close to breaking DRY, especially when you are testing a case or a control block route based on the return of another function. Sometimes, this can be addressed with methods such as data providers, but sometimes, you have no choice but to write tests which are just minor variations of one another.

Lastly, while many don't consider tests which touch a database as a unit test, if you are only testing one public method, especially a static method, which solely queries a related database, sometimes it is far simpler, if not necessary to just setup a test database with a tiny amount of fake data, then let the queries hit the database.  As an example, take the following code in Medusa's User model:

```
public static function getByBilletId(string $billet_id)
{
    $billet_user = null;

    $billet = Billet::where('_id', $billet_id)->first();

    if ($billet instanceof Billet) {
        $billet_user = User::where('assignment.billet', $billet['billet_name'])
            ->first();
    }

    return $billet_user;
}
```
With PHPUnit and Mockery, you are faced with having to execute in a separate process since the odds are that the Billet class has already been loaded. Then there is the fact that while mocking Billet to fake out the calls to the database, you cannot return an actual instance of Billet, but only the mocking class. So it is far easier to seed the database and do the queries. Sadly, while PHPUnit, Mockery and Laravel between them provide a means to do Laravel database migrations (necessary for MongoDB, which does not support database transactions) and seeding, the migrations are turned on at the class level, which slows down all the tests of that test class, not just the ones which need to access the database.

So, as you can see, sometimes in unit testing you have to break the rules.
 

Categories