Previous: part 1 – Next: part 3
If you have done or read anything about unit testing, I’m sure you’ve encountered the standard example of a function that multiplies two numbers and returns the result. This function is pretty easy to test.
Once your functions become more complex than that, it can become a real challenge to figure out how to test them. Let’s look at a function that is slightly more complex than the multiplication one.
function filterItems(items, totalProfit) { return items.filter(function(item) { return item.amount * item.profit >= totalProfit; }); }
This function takes 2 input parameters: a list of items, and a total profit value. It returns a new list, with only the items that have a total profit of at least the given total profit value. The profit is determined by multiplying the amount of items and the profit per item.
You use it like this:
var items = [ { amount: 10, profit: 100 }, { amount: 20, profit: 30 } ]; var filteredItems = filterItems(items, 700); // filteredItems = [{ amount: 10, profit: 100 }];
There is your first test already. What else could you test here?
- An empty list as input
- An object with amount * profit exactly equal to the totalProfit argument
- The same, but now with floating point numbers
- An object with negative profit
- An object with zero amount
- A negative totalProfit argument
I’m not saying you need to test all these, but you could, and they could be meaningful tests. Which ones would you actually choose to implement? Let’s go over them, the normal scenarios first.
Test 1 is simple but also does not contribute very much. Filtering an empty list returns an empty list, no matter what the filter is.
Number 2 is a good one: the function uses the >= operator, not the > operator, and does so for a reason. It’s good to test that.
The third test is an interesting one. Should we care about floating point multiplication issues here? I would like to confirm that the object { amount: 40, profit: 17.5 } will also be in the returned list when calling the function with 700 as second argument. So let’s do it, and see what happens.
The fourth one is not that relevant to me. After all, we know that a number below 0 is smaller than the given (positive) profit. We don’t need to test if the >= operator works correctly.
For the same reason, test 5 is not that useful to me. We know that multiplying a number by zero works.
Finally, test 6: a negative totalProfit argument. Here we come to a different issue: in the system that this function lives in, is it normal to have negative profits, to show losses? Is that an acceptable situation? Or is the profit always zero or higher, because if there is a loss, it’s administered in a different field?
If the profit can be negative, that means that a negative totalProfit argument makes sense. I would add a test with a few objects, some with positive and some with negative profit, and filter on a negative totalProfit. Yes, technically you are again testing the >= operator, but I think this is a valuable test, because this is conceptually different. This test is not for the computer, it is for the people who look at this code in the future.
So, of the 6 tests, I would do 2, 3 and 6.
Oh wait, what about the possible error scenarios?
- Don’t pass a second argument
- Pass a negative amount and a negative profit
- Pass a string as first argument instead of an array of objects
- Pass an array of strings as first argument
- Pass a string as second argument
- Trigger an “out of memory” error when the function is called
Let’s do the same exercise.
Test 1 exposes a problem in our code immediately. Apparently, when in JavaScript you compare any number to undefined , you get false (I had to test that myself too). So instead of an error, you always get an empty array back. Personally I’d rather get an error – much easier to debug.
The second test is funny. Multiplying two negative numbers results in a positive number. So when filtering for 500 , and you pass { amount: -10, profit: -100 } , this object will be in the return array. Now, the question is whether our filterItems function should care that this is possible at all. Maybe the array of items is created via some other function, and thus this scenario could never happen. So, it depends on the rest of the system whether you should make a test for 2.
Number 3 is typically a test that you don’t see often. Most developers, even when writing unit tests, don’t tend to make tests for input that is utterly incorrect. This is a reasonable approach in my opinion, because otherwise you’d be writing dozens of extra tests for each function, with marginal value.
If you’re familiar with JavaScript, you know what will happen if you call filterItems with a string as first argument: you’ll get an error on the items.filter statement. Good enough for me.
Test 4 is tricky. In other languages, you will get an error because a string does not have the field amount or profit . However, in JavaScript, doing “test”.profit gives undefined. So our function will always return an empty array when you use an array of strings as input. Is that a problem? Probably not in this case. filterItems is a very low-level function, so most of the interactions with this function will be done by developers. They should know better.
5 turns out to have the same result as 1. Comparing any number to a string returns false. Again, I’d rather get an error than an empty array as return value.
The sixth test is quite drastic. An out of memory error? I don’t think I want to test this. First, it’s probably going to take some work to simulate this. And second, if this function hits an out of memory error, there will be bigger problems than just this function failing.
So, what do we do with these six error scenarios? With the code as it is, I don’t think that there are any meaningful error tests. I’d probably change the function: I’d add a check to see if the first argument is an array of objects with the fields amount (which should not be negative) and profit, and if not, throw an error. Then I would add a few tests for that: 2, 3, and 4.
Note that in a different language, you would have different problems. For example, if your object oriented language has static typing, and you have defined a ListOfItems class, you can put all these checks in that class and have the language verify that the first argument is a ListOfItems object. Anyway, this post is not about differences of languages, but how to think about testing.
Phew, that’s quite a bit of thinking, and that just for a 3-line function. So far, we have:
- written a few tests for the normal scenarios;
- decided not to test certain scenarios because they are already tested elsewhere;
- written a test purely to help other developers in the future;
- found the need to know how negative profits (losses) are handled in this system;
- found the need to know how these lists of item objects are created;
- modified the code to add a check for the first argument.
Let’s look at a function that is a bit more complex. This function is part of a system to book hotel rooms and it creates a booking.
function createReservation(reservationData) { // moment is a date library. var checkin = moment.utc(reservationData.checkin); if (!checkin.isValid()) { throw new Error('Check-in date ' + reservationData.checkin + ' is not a valid date.'); } var checkout = moment.utc(reservationData.checkout); if (!checkout.isValid()) { throw new Error('Check-out date ' + reservationData.checkout + ' is not a valid date.'); } // The reservation checker is a dependency from outside this function. var isAvailable = reservationChecker.isAvailable({ roomId : reservationData.roomId, checkin : checkin, checkout: checkout }); if (!isAvailable) { throw new Error('Cannot create reservation because room ' + reservationData.roomId + ' is not available.'); } var reservationObj = new Reservation({ checkin : checkin, checkout : checkout, numberOfGuests: reservationData.numberOfGuests, guestEmail : reservationData.guestEmail }); reservationObj.calculateCost(); // Return a promise that resolves to the saved reservation object. return reservationObj.save(); }
This might not be the best code ever, but it’s certainly a function that you could encounter in a custom made reservation system. An early version. Anyway, let’s look at what it does.
The only argument is an object with information about the reservation. This object should contain four fields: checkin , checkout , numberOfGuest , and guestEmail . There are checks done on the checkin and checkout fields, and the availability of the room is checked as well. Then a new Reservation object is created, the cost of the reservation is calculated, and the reservation is saved. A promise is returned that resolves to the saved reservation.
What kind of sunny day scenarios are there? Or, if you prefer, happy path scenarios?
Well, not many. How about:
- A short reservation (1 night)
- A long reservation (1 year)
- A reservation that ends on the day the next reservation starts
- A reservation that begins on the day the previous reservation ends
- Different guest emails
- Different numbers of guests
The first two tests can be used to verify if reservations can be short or long.
The third and fourth test make sure that your logic with checkin/checkout for consecutive reservations is correct. I think these are very important tests here: you don’t want to have days that are double booked, and you also don’t want to have gaps of 1 day between reservations.
The fifth test, testing the email address validity, could be useful – but it should not be tested here. This function is about creating reservations, not managing email addresses. So the validity of the email address should be created elsewhere. Maybe the constructor of the Reservation class calls isValidEmail on the given email address. In that case, there should be tests for thisValidEmail function.
Test 6, different amounts of guests, could be useful. There could be different prices for different amounts of guests. So you could make 2 tests that create a reservation each, for the same room, checkin and checkout, but different numbers of guests. Then you can make sure that the two reservations each have the right price.
On the other hand, those are probably tests that should be done on a lower level: on reservation.calculateCost() . If those tests already exist, you don’t need to duplicate them here. All you need to do is test that calculateCost() is called on the reservation.
What about rainy day scenarios?
Where do we start? That’s going to be a long list.
- Check-out the same day as check-in
- Check-out before check-in
- Room with given id does not exist
- Room is already booked for the given check-in and check-out
- Guest email is not a valid email address
- Number of guests is too large
- Number of guests is too small
- The cost could not be calculated
- Error saving reservation to database
- Check-in is not a date
- Check-out is not a date
- Room id is in the wrong format
- Check-in is a date time, e.g. “2016-02-15 17:31:44”
- Guest email is empty
- Guest email is not an email address
I’m sure you can think of more.
Note that the first 9 scenarios are of a different type than the last 6. The first 9 can happen with each input field being valid in itself. The last 6 are just invalid inputs.
Test 8 leads to some questions. Could this happen? How could this happen? Maybe the room doesn’t have rates defined for the given check-in and check-out dates. Should we test this?
Test 13 is also interesting. What should happen here? Should the function “round down” the date to midnight? What about timezones? Should the checkin and checkout arguments be date strings maybe?
Since this seems to be a very central core function of the system, I would write tests for most of these, except for:
5, because this should be tested elsewhere.
9, because if there is a database error, we have bigger problems than just this function failing.
As you can see, even just thinking about which tests you could write already leads to improvements of the code, extra error checks, finding bugs, and determining which tests should go on which level.