Should savePeople() be unit tested
Yes, it should. But try to write your test conditions in a way that is independent from the implementation. For example, turning your usage example into a unit test:
function testSavePeople() {
myDataStore = new Store('some connection string', 'password');
myPeople = ['Joe', 'Maggie', 'John'];
savePeople(myDataStore, myPeople);
assert(myDataStore.containsPerson('Joe'));
assert(myDataStore.containsPerson('Maggie'));
assert(myDataStore.containsPerson('John'));
}
This test does multiple things:
- it verifies the contract of the function
savePeople()
- it does not care about the implementation of
savePeople()
- it documents the example usage of
savePeople()
Take note that you can still mock/stub/fake the data store. In this case I wouldn't check for explicit function calls, but for the result of the operation. This way my test is prepared for future changes/refactors.
For example, your data store implementation might provide a saveBulkPerson()
method in the future - now a change to the implementation of savePeople()
to use saveBulkPerson()
would not break the unit test as long as saveBulkPerson()
works as expected. And if saveBulkPerson()
somehow does not work as expected, your unit test will catch that.
or would such tests amount to testing the built-in forEach language construct?
As said, try to test for expected results and the function interface, not for the implementation (unless you are doing integration tests - then catching specific function calls might be of use). If there are multiple ways to implement a function, all of them should work with your unit test.
Regarding your update of the question:
Test for state changes! E.g. some of the dough will be used. According to your implementation, assert that the amount of used dough
fits into pan
or assert that the dough
is used up. Assert that the pan
contains cookies after the function call. Assert that the oven
is empty/in the same state as before.
For additional tests, verify edge cases: What happens if the oven
is not empty before the call? What happens if there isn't enough dough
? If the pan
is already full?
You should be able to deduce all the required data for these tests from the dough, pan and oven objects themselves. No need to capture the function calls. Treat the function as if its implementation would not be available to you!
In fact, most TDD users write their tests before they write the function so they are not dependent on the actual implementation.
For your latest addition:
When a user creates a new account, a number of things need to happen: 1) a new user record needs to be created in the database 2) a welcome email needs to be sent 3) the user's IP address needs to be recorded for fraud purposes.
So we want to create a method that ties together all the "new user" steps:
function createNewUser(validatedUserData, emailService, dataStore) {
userId = dataStore.insertUserRecord(validateduserData);
emailService.sendWelcomeEmail(validatedUserData);
dataStore.recordIpAddress(userId, validatedUserData.ip);
}
For a function like this i would mock/stub/fake (whatever seems more general) the dataStore
and emailService
parameters. This function does not do any state transitions on any parameter on its own, it delegates them to methods of some of them. I would try to verify that the call to the function did 4 things:
- it inserted a user into the data store
- it sent (or at least called the corresponding method) a welcome email
- it recorded the users IP into the data store
- it delegated any exception/error it encountered (if any)
The first 3 checks can be done with mocks, stubs or fakes of dataStore
and emailService
(you really don't want to send emails when testing). Since I had to look this up for some of the comments, these are the differences:
- A fake is an object that behaves the same as the original and is to a certain extent indistinguishable. Its code can normally be reused across tests. This can, for example, be a simple in-memory database for a database wrapper.
- A stub just implements as much as needed to fulfill the required operations of this test. In most cases, a stub is specific to a test or a group of tests requiring only a small set of the methods of the original. In this example, it could be a
dataStore
that just implements a suitable version of insertUserRecord()
and recordIpAddress()
.
- A mock is an object that lets you verify how it is used (most often by letting you evaluate calls to its methods). I'd try to use them sparingly in unit tests since by using them you actually try to test the function implementation and not the adherence to its interface, but they still have their uses. Many mock frameworks exists to help you create just the mock you need.
Note that if any of these methods throws an error, we want the error to bubble up to the calling code, so that it can handle the error as it sees fit. If it's being called by the API code, it may translate the error into an appropriate HTTP response code. If it's being called by a web interface, it may translate the error into an appropriate message to be displayed to the user, and so on. The point is this function doesn't know how to handle the errors that may be thrown.
Expected exceptions/errors are valid test cases: You confirm, that, in case such an event happens, the function behaves the way you expect it would. This can be achieved by letting the corresponding mock/fake/stub object throw when desired.
The essence of my confusion is that to unit test such a function it seems necessary to repeat the exact implementation in the test itself (by specifying that methods are called on mocks in a certain order) and that seems wrong.
Sometimes this has to be done (though you mostly care about this in integration tests). More often, there are other ways to verify the expected side effects/state changes.
Verifying exact functions calls makes for rather brittle unit tests: Only small changes to the original function causes them to fail. This can be desired or not, but it requires a change to the corresponding unit test(s) whenever you change a function (be it refactoring, optimizing, bug fixing, ...).
Sadly, in that case the unit test loses some of its credibility: since it was changed, it does not confirm the function after the change behaves the same way as before.
For an example, consider someone adding a call to oven.preheat()
(optimization!) in your cookie baking example:
- If you mocked the oven object, it won't expect that call and fail the test, although the observable behavior of the method did not change (you still have a pan of cookies, hopefully).
- A stub might or might not fail, depending on whether you only added the methods to be tested or the whole interface with some dummy methods.
- A fake should not fail, since it should implement the method (according to the interface)
In my unit tests, I try to be as general as possible: If the implementation changes, but the visible behavior (from the perspective of the caller) is still the same, my tests should pass. Ideally, the only case I need to change an existing unit test should be a bug fix (of the test, not the function under test).