Writing Tests
Tests can be written in various ways. This section tries to give a standard and a rationale behind the way we writes tests.
Tests MUST be written using the describe and the it methods provided by jest
Describing and building context
The describe method SHOULD be used to describe, whenever it's possible, the context on which the piece of code to be tested will be executed and SHOULD build that context inside of its scope.
describe('when the flag is red', () => {
...
})
describe('when the flag is blue', () => {
...
})To describe the context, we SHOULD use one of the following words: when for top level describes and having or and to indicate that there's a context that depends on other context that was defined before. The rationale behind this is to have a self described contexts that can be easily understood and checked by combining the describe sentences. As the idea is to concatenate the descriptions of the contexts, describes MUST be written in lowercase.
describe('when the flag is red', () => {
...
describe('and the wind is strong', () => {
// The context here is described by the conjuncton of the describes:
// "when the red flag is red and the wind is strong"
...
})
})Multiple describes can be nested at the same and at a lower level. Describes at the same level denote different contexts and describes in lower levels (nested describes) denote a more specific context.
As mentioned before, each describe SHOULD be accompanied by a method that builds the context or a method that destroys or changes the context. The methods that MUST be used are the beforeEach and the afterEach.
The Jest framework has two methods, beforeEach and beforeAll, but combining both of them generates confusion and execution problems when nested contexts are present, as the order on which they are executed is not what we would expect.
Contexts created with the beforeEach method will be more and more defined as we go deeper into the describe tree. Each beforeEach will specify the context more according to what their text says.
Variable Scoping
The variables set in the contexts SHOULD be defined according to the scope that they're going to be used in. That is, if a variable is used only for a specific context in a nested level of describes, the variable SHOULD be defined only on that specific context. The rationale behind this is to keep the variables close to the code that they're being used in, making the code easier to understand.
Variables of primitive types, that won't be changed in the context that they're defined or in subsequent contexts SHOULD be defined as const once, over the beforeEach definition in the scope.
Variables of primitives types that will be changed in the context that they're defined or in subsequent contexts MUST be defined as let, as the nature of Typescript obliges it.
Variables of non primitive types (objects, arrays, etc) MUST be defined as let and their value MUST be set in a beforeEach. The rationale behind this is that objects in JS are mutable, that means that any execution of code that uses that object is subject to change and affect other tests in an involuntarily manner.
Variables SHOULD be correctly typed whenever possible. Repeated types SHOULD be abstracted into a type.
Describing expectations and executing code
The it method MUST be always placed inside the scope of a describe and MUST be used to describe what is that we expect from the code that we're going to test and SHOULD contain, if possible, only one assertion. Multiple assertions per it can exist if there's a performance problem, as the tests will be ran once per it (because of the beforeEach), or if what is expected can be described with clarity in the expectation description.
The rationale behind this structure is to provide clarity to the reviewer of the tests and to the developers that will maintain and make changes to the code as each context and expectation is clearly enumerated, making it easier to understand what is being tested, how it is tested, and what's left to test.
Following the describe descriptions down to the its the developers can easily understand what is being tested by concatenating the sentences.
Writing Clear Expectations
The description of the expectations written in the its MUST be as descriptive as possible about what it is expected from the execution of the tests. The develops MUST NOT use abstract or general phrasing to define expectations as it removes clarity on what is being expected from the code.
The developer MUST NOT use phrases like:
"should work as expected" ⇒ What is to work as expected?
"should return the correct value" ⇒ What is the correct value?
"should resolve/return/work correctly" ⇒ How does something work correctly?
"should fail" ⇒ How should it fail? What's the message that it should provide?
It must be taken into consideration that when specifying what is expected in the its or what the context will be in the describes the developers MAY use function names or exact error messages if it is required to better understand the intention, but they SHOULD use a textual representation when possible to make tests easier to maintain.
What to test
Choosing what to test can vary depending on the code that is being executed. Different factors, from performance or a big domain of inputs can definitely change what should or should not be tested from the tests. Here we'll expose a single set of cases that the developer SHOULD test if possible.
The run function here has a couple of different execution flows. Functions, or sections of code, SHOULD NOT be tested based only on the different possible execution flows, but in what is expected from the function, as the function might not be doing what it is was written for and the tests could be tightly coupled with the code and not catch different issues with it. This implies that the developer SHOULD always test all the possible execution paths and SHOULD test other possible paths too in accordance with the semantics of the function.
For this specific case, the developer SHOULD test at least the following cases:
Running the run function with an amount of kilometers greater than 1000
Running the run function with an amount of kilometers equal to 10
Running the run function with an amount of kilometers greater than 10 but lower than 1000
For the first case, the developer needs to test that the run function throws an exception. Exceptions MUST be checked for its error message, as any other exception could be also thrown, invalidating the purpose of the test. If the exception is a custom exception, the developer MAY check for the instance of the exception.
For the second case, the developer needs to test that the function returned the same number of kilometers that is was given.
For the third case, the developer needs to test that the function returned what an external function, beLazy returned and, as doSomething (also executed in the method) can't be checked through the return value of the function, the developer SHOULD test that it was called with the correct arguments.
When to mock
Mocking will depend mostly on the type of tests that the developer is writing.
Unit tests SHOULD have every external function mocked, with the exception of functions that do simple stuff, like formatting strings, etc.
API Tests SHOULD only have mocks for communicating with external services, that is, DBs or different APIs. If one of the operations that is done in the test affects the performance of the test suite, a mock could be implemented to mitigate this issue.
All mocks MUST be done using the tools provided by Jest, with the exception of the redux-saga-test-plan mocks.
Mocks and utility functions
Any Jest mocks SHOULD be mocked using their once method when possible, that is,
mockReturnValueOnceormockResolvedValueOnceis recommended againstmockReturnValueormockResolvedValue. The rationale behind this is to prevent unwanted mocks from executing, changing the execution of our tests.Jest offers a bunch of Types that you can use for different kind of mocks. For example, when mocking an object you SHOULD use
jest.Mocked<typeof someObject>, for classes you SHOULD usejest.MockedClass<typeof SomeClass>, and for functions,jest.MockedFunction<typeof someFunction>.An
afterEachSHOULD be written to run after each tests with ajest.resetAllMocksto clear any mocked implementation of globally mocked modules that might leak into other tests. You can ignore this reset if you are usingmockReturnValueOnceormockResolvedValueOncebecause it is done automatically for you.A directory named
mocksMAY be created to store mocks, which should be placed in thetestorspecdirectory. Big mocks MUST be stored in different files from the tests to avoid having extended test files. Small mocks, if there aren't many, SHOULD be kept in the tests.Mock files SHOULD be named as the entity they're mocking, so for example, if we're mocking a profile, the mock should be placed under the
/test/mocks/profile.tsfile.In case several big mocks are required, they SHOULD be placed in different files inside a directory called as the entity to be mocked, so for example, if we have two profile mocks, we'll store them as:
/test/mocks/profile/profile-with-wearables.tsand/test/mocks/profile/profile-without-wearables.ts. To easy access them, you SHOULD create anindex.tsfile inside the/test/mocks/profile/directory and export them.In order to avoid mutation of mocked objects, mocks MUST be exported as functions that return them.
Last updated