What's the primary purpose of testing? This is a pretty substantial question. The answer to this question will give you an understanding of both how to write tests and how not to.
Imagine you've written a function, capitalize(text)
, that capitalizes the first letter of the string passed to it:
capitalize('hello'); // 'Hello'
Here's how you can implement it:
const capitalize = (text) => {
const firstChar = text[0].toUpperCase();
const restSubstring = text.slice(1);
return `${firstChar}${restSubstring}`;
};
What do we do after creating a function? Let's check how it works. For example, we open a REPL and call a function with different arguments:
node
Welcome to Node.js v12.4.0.
> capitalize('hello')
'Hello'
> capitalize('how are you')
> 'How are you'
It is a painless way to ensure that the function works. At least for the arguments, we passed to it. If you notice errors during the test, fix the function and run again.
This whole process is what testing is at heart; not automatic, but rather manual testing. The purpose of this testing is to make sure that the code works as it should. It doesn't matter at all how exactly this function is implemented. This is the main answer to the question posed at the beginning of the lesson.
Tests verify that code (or an application) works correctly. And they don't care about how exactly the code they're checking is written.
Automated tests
All the automated tests have to do is repeat the checks from the manual tests. The good old-fashioned if
and exceptions are enough for that.
Even if you're not familiar with exceptions, that's okay. In this course, we require only two things to know: what we need them for and what their syntax is. In Hexlet courses, you've mainly encountered unintentional errors, such as calling a non-existent function, referring to a non-existent constant, and so on. But errors can appear on their own thanks to exceptions, which we need for our case. Exceptions are created like so:
// Literally throwing out a new error
// Exceptions are thrown
throw new Error('description of the exception');
// The code following this expression won't be executed and will terminate with an error
console.log('nothing');
Example of a test:
if (capitalize('hello') !== 'Hello') { // If the function output is not equal to the expected value
// Throw an exception and terminate the test
throw new Error('The function is not working properly!');
}
You can see from the example above that tests are just like any other code. It works in the same environment and is subject to the same rules, e.g., coding standards. It may also contain errors. But that doesn't mean you have to write tests that test tests. It's impossible to escape errors at all, and you don't need to, otherwise, the development cost would be unreasonably high. The errors detected in the test are corrected, and life goes on ;)
In code, tests are usually placed in a special directory at the root. It's usually called tests, though there are other options:
src/
├── bin
│ └── hexlet.js
├── half.js
└── index.js
tests/
└── half.test.js
The structure of this directory depends on what the tests are written on, i.e., what framework they're written on. In simple cases, it reflects the structure of the source code. Assuming that our capitalize(text)
is defined in the src/capitalize.js, file, its test should be placed in tests/capitalize.test.js. The word test in the name of the module is used to highlight the file's purpose.
Now, whenever you make any changes that affect this feature, it's important to remember to run tests:
node tests/capitalize.test.js
# If all is well, the code will run quietly without any issues
# If there's an error, a relevant message will be displayed
How tests are written
There's no magic. As developers, we need to import the test functions ourselves, call them with the necessary arguments, and verify that the functions return the expected values.
If the function signature (input or output parameters, and its name) has changed, you'll have to rewrite the tests. If the signature remains the same, but the internals of the function have changed:
const capitalize = (text) => {
const [firstChar, ...restChars] = text;
return `${firstChar.toUpperCase()}${restChars.join('')}`;
};
Then the tests should continue to work without changes.
Good tests know nothing about the inner workings of the code being tested. This makes them more versatile and reliable.
How many and what kind of checks do I need to write?
It's impossible to write tests that guarantee that your code will work 100% of the time. This would require that we implement checks on all possible arguments, which is not physically possible. On the other hand, without tests there are no guarantees at all, other than what the developers say.
When writing tests, you need to focus on the potential variety of input data. Any function has one or more basic usage scenarios. For example, for capitalize()
, it would be any word. You can cover this scenario with just one check. Next, we need to look at borderline cases. These are situations where the code can behave unusually:
- Working with an empty string
- Processing
null
- Dividing by zero (causes an error in most languages)
- Specific situations for specific algorithms
For capitalize()
the borderline case is an empty string:
if (capitalize('') !== '') {
throw new Error('The function is not working properly!');
}
By adding a test on an empty line, we will see that the capitalize()
call shown at the beginning of the lesson ends with an error. Within it, the first index of the string is called without any checks to see if it exists. Fixed version of the code:
const capitalize = (text) => {
if (text === '') {
return '';
}
const firstChar = text[0].toUpperCase();
const restSubstring = text.slice(1);
return `${firstChar}${restSubstring}`;
};
In a large number of situations, borderline cases require individual treatment and using conditional constructions. Tests should address each of these constructions. But don't forget that conditional constructions can generate tricky and unexpected connections. For example, two independent conditional blocks can generate 4 possible scenarios:
- No conditional block was executed
- Only the first conditional block was executed
- Only the second conditional block was executed
- Both conditional blocks were executed
The combination of all possible variants of function behavior is called cyclomatic complexity. This number shows all possible paths for the code within a function. Cyclomatic complexity is a good benchmark for understanding how many and which tests to write.
Sometimes borderline cases do not involve conditional constructions. These situations are especially common when working with word and array boundaries. This code can work in the vast majority of situations, but in some cases, it may fail.
// In this function, they forgot to subtract one from the length
// This code will work in some situations where the last element is undefined or there are no elements in the array
// But in other cases, it'll return an invalid value
const last = (elements) => elements[elements.length];
Checking input data
Errors about input data catch the eye. For example, what if you pass a number instead of a string into the capitalize()
function? How should it behave in this case? Does a test for it need to be written?
Another interesting question. Does capitalize()
need to handle these situations internally? The answer is no. Otherwise, the code will turn into garbage, and it won't be of much use. There'll still be tests that verify that the system works as a whole, and they usually reveal problems with the code at lower levels.
The capitalize()
function shouldn't bear the responsibility for the data that's passed to it, it should be on the code that calls that function. And if it's tested properly, an error like this will either be detected or not occur at all.
But even if an error is handled within a function, you shouldn't try to write tests covering every error. This increases the number of tests that require support and time to write. You need to be able to stop at the right time and move on to cover a different part of your code.
Putting it all together
We've ended up with this directory structure:
src/
└── capitalize.js
tests/
└── capitalize.test.js
Test contents:
if (capitalize('hello') !== 'Hello') {
throw new Error('The function is not working properly!');
}
if (capitalize('') !== '') {
throw new Error('The function is not working properly!');
}
console.log('All tests passed!');
Launch:
node tests/capitalize.test.js
https://repl.it/@hexlet/js-testing-goal-capitalize-en#tests/capitalize.test.js
If everything is written correctly, the tests will end with the line All tests passed! If there is an error in the tests or in the code, it'll throw an exception and we'll see a message about it.
Do it yourself
- Reproduce the structure from the end of the lesson
- Run the tests, make sure they work. Try to break them
- Add the code to GitHub
Are there any more questions? Ask them in the Discussion section.
The Hexlet support team or other students will answer you.
For full access to the course you need a professional subscription.
A professional subscription will give you full access to all Hexlet courses, projects and lifetime access to the theory of lessons learned. You can cancel your subscription at any time.