This repo will endeavour to provide a basic overview of writing and using unit tests in Typescript. Most of the concepts covered also apply to vanilla Node JS and other Javascript supersets.
This guide assumes a basic working knowledge of Typescript
Any test-driven development has one goal: consistency. This goal is usually achieved by writing a number of tests for the desired functionality or current api specifications. There are three (sometimes four) main types of software testing 1.
This test-level information is primarily needed for understanding the context, you can skip ahead if you want.
This is a more classical approach to test levels:
-
Unit or Component Testing
Test object: components, program modules, functions, programs, database modules, SQL requests, depending on the granularity of the software or system to test.
Objective: detect failures in the components, verifies whether the mode of operation of the component, module, program, object, class, etc., is functional or non-functional.
Entry criteria: the component is available, compiled, and executable in the test environment; the specifications are available and stable.
Exit criteria: the required coverage level, functional and technical (or non-functional), has been reached; defects found have been corrected; and the corrections have been verified.
Transparency: Can be based on the structure with source access (white-box tests) or on the requirements or the interfaces (black-box tests).
Responsible party: Developers
-
Integration Testing
Test object: components, infrastructure, interfaces, database systems, and file systems.
Objective: detect failures in the interfaces and exchanges between components.
Reference material: preliminary and detailed design documentation for the software or system, software or system architecture, use cases, workflow, etc.
Entry criteria: at least two components that must exchange data are available, and have passed component test successfully.
Exit criteria: all components have been integrated and all message types (sent or received) have been exchanged without any defect for each existing interface.
Transparency: Can be based on the architecture of the code (white-box tests) or on the specifications (black-box tests).
Responsible party: Developers
-
End-to-end Testing (E2E or System Testing)
Test object: the complete software or system, its documentation (user manual, maintenance and installation documentation, etc.), the software configuration and all the components that are linked to it (installation and de-installation scripts, etc.).
Objective: detect failures in the software, to ensure that it corresponds to the requirements and specifications, and that it can be accepted by the users.
Reference material: requirements and specifications of the software or system, use cases, risk analysis, applicable norms, and standards.
Entry criteria: all components have been correctly integrated, all components are available.
Exit criteria: the functional and technical (i.e. non-functional) level of coverage has been reached; must-fix defects have been corrected and their fixes have been verified; the summary test report has been written and approved.
Transparency: Usually based on specifications (black-box tests). However, it is possible to base some tests on the architecture of the system — call graph for example — and to execute some white-box tests.
Responsible party: Independent test teams and internal or external QA.
-
Acceptance Testing
Test object: the complete software or system, its documentation, all necessary configuration items, forms, reports and statistics from previous test levels, user processes.
Objective: obtain customer or user acceptance of the software.
Reference material: contract, specifications, and requirements for the system or software, use cases, risk analysis, applicable standards, and norms.
Entry criteria: all components have been correctly tested at system test level and are available, the software installs correctly, the software is considered sufficiently mature for delivery.
Exit criteria: the expected coverage level has been reached; must-fix defects have been corrected and the fixes have been verified; user and customer representatives who participated in the acceptance test accept that the software or system can be delivered in production.
Transparency: Acceptance test are mostly black-box tests (based on requirements and specifications), though some structure based tests can be executed.
Responsible party: Internal QA/Testers, internal leadership, and end users.
Mike Cohn coined the term "test pyramid" to simplify this relatively complicated arrangement2. This more modern model is more geared toward cloud-based app and microservices, but is commonly dismissed as overly simplistic.
The most important takeaway from Cohn's method is unit tests should be the foundation of development.
It would be just as easy, probably easier, to cover this topic without using static typing. However, Typescript is quickly becoming the standard for developing large codebases and node packages. For projects that seek to have a wide developer base and a high level of maintainability, Typescript is the best option for Javascript development.
This repo is broken up to a number of sections, each of which has a corresponding folder containing example code of the concepts covered. Each section will have two basic npm scripts to run npm install
to install the section's dependencies, and npm test
to run the unit test(s).
This is the most basic level of unit test, and is a good place to start the conversation. A "pure function" is defined as a function that's return value, f(x) is the same for the same given argument(s), x. 3
To test a pure function, a number of test cases of expected input and output are designated, and they're run until the function passes all the test cases. This is technically a type of black-box testing because the test doesn't care what happens to get the expected output.
We'll write a function called doubleMe that will: "return double the value of a given integer"
Borrowing from our textbook definition, we can describe this test as:
- Test object: our function doubleMe
- Objective: test a function for compliance with the goal
- Entry criteria: the function executes with one test case
- Exit criteria: all test cases pass
- Transparency: black-box
Our test case will consist of an in number and an out number.
/**
* A test case and its expected outcome
**/
interface Case {
inNum: number
outNum: number
}
Now that we've defined what our test case will look like, let's create a few.
/**
* Test cases for doubleMe
**/
const cases: Case[] = [
// Edge cases
{
inNum: 0,
outNum: 0
},
{
inNum: 10100000100000,
outNum: 20200000200000
},
// Normal cases
{
inNum: 1,
outNum: 2
},
{
inNum: 331,
outNum: 662
},
// Negative cases
{
inNum: -100,
outNum: -200
},
{
inNum: -12885,
outNum: -25770
}
]
Negatives!? When we started writing our test cases, we hadn't even thought about negative numbers. Discovering new and weird edge cases is one of the best parts of testing! It drives standardization and forces the developer to define clear rules and expectations (aka API and documentation) for their code.
Writing seven test cases for such a trivial function might be excessive. The International Software Testing Qualifications Board (ISTQB) guidelines state the second principle of software testing is that it is impossible to test everything1, and this is especially true when tests are expensive or take a lot of time to run. If tests are too inconvenient, they might be circumvented for expedience, which completely defeats the purpose.
Choosing which test cases to include is a tough balancing act that is part of risk management for a project, and the list may change a number of times throughout the development life cycle.
Now we need to write the function to test! We've written our cases already, so we can have a more descriptive description.
/**
* Returns an integer double the distance from zero as the input
**/
function doubleMe (inNum: number): number {
return inNum + inNum
}
There are countless tools available for testing software more easily, but for this first one, we'll do it manually for demonstration.
/**
* Runs a provided function with the provided positional argument and
* checks the result against the provided expected result
**/
function pureTest (inputValue: number, expectedOutputValue: number, functionToTest: any): void {
const outputValue = functionToTest(inputValue)
if (expectedOutputValue !== outputValue) {
console.error(`Expected output for ${inputValue} is ${expectedOutputValue}. Function returned ${outputValue}`)
} else {
console.log('ok', `${expectedOutputValue} = ${outputValue}`)
}
}
/**
* Call the test for each case
*/
for (const c of cases){
pureTest(Number(c.inNum), Number(c.outNum), doubleMe)
}
Reminder: you can view the example in
sections/one
When working in Typescript there are two very important node 'scripts' that we can define. npm run build
will transpile the Typescript to Javascript, and npm test
will run the tests. For this first section things are very simple, so we can daisy chain those two into one test script.
- Make a new folder and set it as the working directory, we'll use
mkdir one && cd one
- Initialize the node package with
npm init
you can leave all the prompts at default. - Create your tsconfig.json file with
npx tsc --init
- Run
npm -install --save typescript
to install the typescript package - Also run
npm install -g typescript
to install typescript CLI tools globally
Open the newly created package.json
file in your text editor and add these scripts:
{
"name": "section-one",
"main": "index.js",
"scripts": {
"test": "npm run build && node index.js",
"build": "tsc"
},
"dependencies": {
"typescript": "^3.7.3"
}
}
Finally, create index.ts
. This is where all of our code above will live.
Now all we need to do is type npm test
in the shell and our code will automatically transpile to JS and test itself!
Your output should look something like this:
Computer:one username$ npm test
> section-one@1.0.0 test /Users/username/one
> npm run build && node index.js
> section-one@1.0.0 build /Users/username/one
> tsc
ok 0 = 0
ok 20200000200000 = 20200000200000
ok 2 = 2
ok 662 = 662
ok -200 = -200
ok -25770 = -25770
Most developers would agree that writing tests like we did in Section One isn't very practical. The manual custom testing would work if we were just trying to develop an algorithm or lambda, but for anything more complex, we'd spend all our dev time writing test cases and managing the test infrastructure. Luckily, there are countless testing frameworks for every language that provide helper functions and a more structured way to compose tests.
We'll use Facebook's jest as our testing framework. Jest is well-supported and broadly used by Javascript and Typescript developers.
Working off of the same files from the previous section, only a few changes are needed.
Organize our Source Code
Move index.ts
file into a new src
subdirectory in the package folder. This will allow us to keep things organized as our package grows.
Note that you can still run the build script, but now the output ends up in src, next to the Typescript. The source code should be separate from the transpiled code so we can tell which is which and edit our source more easily.
To address this, uncomment the tsconfig.json file line with // "outDir": "./"
and change the value to "outDir": "./dist"
. Now when we build our code transpiles src
=>dist
.
Install Jest
Install jest, its types, and the Typescript plugin using:
npm i jest @types/jest ts-jest -D
Jest Settings
It's common practice in linting and testing libraries to have a specific configuration file in the root of a package. For jest this is jest.config.js
. However, having a number of these files starts to create a messy and annoying workspace.
Node has a newer, elegant way to handle configurations by keeping them in the package.json file.
Update your package to look like the one below:
- Now the test script is a simple call to the jest package
- We updated the main for the dist folder
- We added the jest config to find the tests and map the typescript lines to the javascript output
- Jest and its friends are in the dev dependencies
{
"name": "section-two",
"main": "./dist/index.js",
"scripts": {
"test": "jest",
"build": "tsc"
},
"jest": {
"roots": [
"<rootDir>/src"
],
"testMatch": [
"**/?(*.)+(spec|test).+(ts|tsx|js)"
],
"transform": {
"^.+\\.(ts|tsx)$": "ts-jest"
}
},
"dependencies": {
"typescript": "^3.7.3"
},
"devDependencies": {
"@types/jest": "^24.0.23",
"jest": "^24.9.0",
"ts-jest": "^24.2.0"
}
}
Run an npm install
for good measure, and jest is ready to go!
Jest follows the standard definition of tests, they'll usually look like this:
describe("unit name", () => {
it("should do a thing", () => {
// Do the thing
})
it("should do another thing", () => {
// Do the other thing
})
})
Create a new file called src/index.test.ts
. This naming scheme will get automatically detected by jest and run the tests it contains.
Move all of our code, except for the doubleMe
function from src/index.ts
to src/index.test.ts
. Our source source code is now significantly cleaner without all the test logic getting in the way.
/**
* Returns an integer double the distance from zero as the input
**/
function doubleMe (inNum: number): number {
return inNum + inNum
}
To complete our refactor, we need to export the function so we can test it, and import it into the test file.
// export the function from index.ts
export function doubleMe (inNum: number): number {
return inNum + inNum
}
// add this to the top of index.test.ts
import { doubleMe } from './index'
// test code below
If you try to run npm t
to test your package now, you'll get an error like "Your test suite must contain at least one test.". The tests that you originally wrote will still run and log out their results, but not in a way jest understands.
This is where the "describe -> it -> test" syntax comes into play.
Our testing code before looked like this.
/**
* Runs a provided function with the provided positional argument and
* checks the result against the provided expected result
**/
function pureTest (inputValue: number, expectedOutputValue: number, functionToTest: any): void {
const outputValue = functionToTest(inputValue)
if (expectedOutputValue !== outputValue) {
console.error(`
Expected output for ${inputValue} is ${
expectedOutputValue
}. Function returned ${outputValue}`)
} else {
console.log('ok', `${expectedOutputValue} = ${outputValue}`)
}
}
/**
* Call the test for each case
*/
for (const c of cases) {
pureTest(Number(c.inNum), Number(c.outNum), doubleMe)
}
We can combine all of this code into a few lines of jest!
// Leave Case and cases unchanged
// Delete pureTest and the for loop
describe('test doubleMe', ()=>{
it ('should double numbers', ()=>{
for (const c of cases) {
// We'll talk about expect in the next section
expect(doubleMe(c.inNum)).toBe(c.outNum)
}
})
})
Now if we run npm t
we get:
PASS src/index.test.ts
test doubleMe
✓ should double numbers (5ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
The exercises with doubleMe introduced the basic frameworks and concepts of Typescript testing in the jest environment. But we're using the most basic type of testing, especially for a function, absolute equality cases.
There are two general types of test assertions, or logical propositions that evaluate to a boolean.
- Absolute Assertions
- Exactly defined input and output values
- Implemented with statements like: 'is', 'toBe', '==='
- Good for covering rare outcomes, or edge cases
- Rigid, but best for black-box use cases or mathematical operations
- Relative Assertions
- More vaguely defined and flexible
- Less likely to detect edge case failure
- Easier to maintain
So far, all our tests have been expecting an output for a given input, absolute. This is an important type of unit test for simple components, but far from the only one in our toolbox. In more complex code, a developer can't possibly write enough tests exclusively with absolute assertions. This is when we must start to rely on more relative, dynamic measures than our doubleMe tests.
I think it's fair to say that doubleMe has outgrown its usefulness. We need a component with more complexity, and handling API calls is a great example use case. The rest of the guide will center around creating a consumer component for the Star Trek API.
You don't have to know anything about star trek, they just have a free api and a high rate limit
Let's start off by copying the folder from the previous session and naming the copy folder three
.
Now delete the entire contents of the src/index.ts
and src/index.test.ts
files.
Now that we're progressing to a more realistic use case for unit testing, we should strive for test-first, or test-driven, development. This test-first mentality is a perfect pairing with Typescript, which provides static checking while you code, and encourages defining data types and properties before they're constructed.
Step One: Define the purpose of the unit The unit will take a name and return how many characters in Star Trek have that name.
Step Two: Define the unit api (params and return values) and create a mock function
In our src/index.ts
we can add the following type:
type nameSearch = (nameString: string) => number
export const nameSearcher: nameSearch = (nameString) => {
console.log(`Input: ${JSON.stringify(nameString)}`)
return 0
}
Step Three: Define edge cases and write tests for them
- Empty strings
- Strings longer than 64 characters
- Client is offline
- There are more than one page worth of results
- Illegal character in the string
Here is how we would start writing this out in src/index.test.ts
:
describe('test Star Trek name search', ()=>{
test.todo('handle empty strings')
test.todo('handle long strings >64')
test.todo('handle offline status')
test.todo('handle too many results')
test.todo('handle illegal characters')
})
These todos let us outline what our test cases look like without getting bogged down with details right away. Running npm t
now won't cause any errors:
$ npm t
PASS src/index.test.ts
test Star Trek name search
✎ todo handle empty strings
✎ todo handle long strings >64
✎ todo handle offline status
✎ todo handle too many results
✎ todo handle illegal characters
Now that the first batch of tests is outlined, we need to decide how we're handling their logic in our component.
- Homès, B. (2012). Fundamentals of software testing.
- https://martinfowler.com/articles/practical-test-pyramid.html
- https://en.wikipedia.org/wiki/Function_(mathematics)