When you’re testing a function, you want to ensure that the provided inputs have the expected outputs. How the function achieves those outputs should be of limited concern to the test.
Ideally, you’ll know what edge cases to cover in your test based on your knowledge of the function’s requirements. You can use your knowledge of the implementation to make sure you covered all those cases; nothing wrong with that. So where do we draw the line?
When you’re writing your tests, they shouldn’t break if the implementation changes, but the results are still the same. Let’s consider an example.
We have a function that automatically assigns ontology concepts to a document. Sometimes it’s not really a good match, so we have another function that lets an admin remove a concept from a document. When that happens, we don’t want the concept to be re-assigned if we need to re-run the automated process, so we add a block.
Here’s one way to test this functionality:
- In the setup, manually create a triple that blocks a concept from being assigned to one of the test documents.
- Call the function that asks whether the concept is blocked from the test document. Verify that it is.
This test will work until the implementation changes. Right now it’s a triple; what if we decide to store blocks in a JSON document instead?
That block is a requirement; it’s implementation doesn’t really matter. Consider this as a test sequence:
- Run the auto assignment. Verify that a particular concept was assigned.
- Call the function to remove that concept. Verify that it’s gone.
- Call the function that asks whether the concept is blocked from a particular document. Verify that it is.
- Run the auto assignment again. Verify that the blocked concept was not assigned this time.
With this approach, the test doesn’t know how the blocks are implemented and it doesn’t care. Instead, the test asks: 1) was the block created? and 2) did the block work? If we change the implementation, the tests remain valid.