By James Kolce. Originally published on SitePoint.
Programming is not only about giving the computer instructions about how to accomplish a task, it’s also about communicating ideas in a precise way with other people, or even to your future self. Such communication can have multiple goals, maybe to share information or just to allow easier modifications—it’s hard to change something if you don’t understand it or if you don’t remember what you did long time ago. Documentation is key, either as simple comments in your code or as whole documents describing the overall functionality of a program.
When we write software we also need to make sure that the code has the intended functionality. While there are formal methods to define semantics, the easiest and quickest (but less rigorous) way is to put that functionality into use and see if it produces the expected results.
Most developers are familiar with these practices: code documentation as comments to make explicit the goal of a block of code, and a series of tests to make sure functions give the desired output.
But usually documentation and testing are done in different steps. By unifying these practices, we can offer a better experience for anyone involved in the development of a project. This article explores a simple implementation of a program to run JavaScript specifications that work for both documentation and testing.
We are going to build a command-line interface that finds all the specification files in a directory, extracts all the assertions found inside each specification and evaluates their result, finally showing the results of which assertions failed and which ones passed.
The Specification Format
Each specification file will export a single string from a template literal. The first line can be taken as the title of the specification. The template literal will allow us to embed JS expressions between the string and each expression will represent an assertion. To identify each assertion we can start the line with a distinctive character, in this case, we can use the combination of the bar character (|
) and a dash (-
), which resembles a turnstile symbol that can sometimes be found as a symbolic representation for logical assertions.
The following is an example with some explanations of its use:
const dependency = require('./dependency')
module.exports = `
Example of a Specification File
This project allows to test JavaScript programs using specification files.
Every *.spec.js file exports a single template literal that includes a general
explanation of the file being specified. Each file represents a logical
component of a bigger system. Each logical component is composed of several
units of functionality that can be tested for certain properties.
Each one of this units of functionality may have one or more
assertions. Each assertion is denoted by a line as the following:
|- ${dependency} The dependency has been loaded and the first assert has
been evaluated.
Multiple assertions can be made for each file:
|- ${false} This assertion will fail.
|- ${2 + 2 === 4} This assertion will succeed.
The combination of | and - will form a Turnstile ligature (|-) using the appropriate
font. Fira Code is recommended. A Turnstile symbol was used by Gottlob Frege
at the start of sentenses being asserted as true.
The intended usage is for specification-first software. Where the programmer
defines the high level structure of a program in terms of a specification,
then progressively builds the parts conforming that specification until all
the tests are passed. A desired side-effect is having a simple way to generate
up-to-date documentation outside the code for API consumers.
`
Now let's proceed with the high level structure of our program.
The Structure of Our Program
The whole structure of our program can be defined in a few lines of code, and without any dependencies other than two Node.js libraries to work with the filesystem (fs
) and directory paths (path
). In this section we define just the structure of our program, function definitions will come in the next sections.
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const specRegExp = /\.spec\.js$/
const target = path.join(process.cwd(), process.argv[2])
// Get all the specification file paths
// If a specification file is provided then just test that file
// Otherwise find all the specification files in the target directory
const paths = specRegExp.test(target)
? [ target ]
: findSpecifications(target, specRegExp).filter(x => x)
// Get the content of each specification file
// Get the assertions of each specification file
const assertionGroups = getAssertions(getSpecifications(paths))
// Log all the assertions
logAssertions(assertionGroups)
// Check for any failed assertions and return an appropriate exit code
process.exitCode = checkAssertions(assertionGroups)
Because this is also the entry point of our CLI (command-line interface), we need to add the first line, the shebang, that indicates that this file should be executed by the node
program. There is no need to add a specific library to handle the command options, since we are only interested in a single parameter. However, you may consider other options if you plan to extend this program in a considerable manner.
To get the target testing file or directory, we have to join the path where the command was executed (using process.cwd()
) with the argument provided by the user as the first argument when executing the command (using process.argv[2]
). You can find a reference to these values in the Node.js documentation for the process object. In this way we obtain the absolute path of the target directory/file.
Now, the first thing that we have to do is to find all the JavaScript specification files. As seen in line 12, we can use the conditional operator to provide more flexibility: if the user provides a specification file as the target then we just use that file path directly, otherwise, if the user provides a directory path then we have to find all the files that match our pattern as defined by the specRegExp
constant, we do this using a findSpecifications
function that we will define later. This function will return an array of paths for each specification file in the target directory.
In line 18 we are defining the assertionGroups
constant as the result of combining two functions getSpecifications()
and getAssertions()
. First we get the content of each specification file and then we extract the assertions from them. We will define those two functions later, for now just note that we use the output of the first function as the parameter of the second one, thus simplifying the procedure and making a direct connection between those two functions. While we could have just one function, by splitting them, we can get a better overview of what is the actual process, remember that a program should be clear to understand; just making it work is not enough.
The structure of the assertionsGroup
constant would be as follows:
assertionGroup[specification][assertion]
Next, we log all those assertions to the user as a way to report the results using a logAssertions()
function. Each assertion will contain the result (true
or false
) and a small description, we can use that information to give a special color for each type of result.
Finally, we define the exit code depending on the results of the assertions. This gives the process information about how the program ended: was the process successful or something failed?. An exit code of 0
means that the process exited successfully, or 1
if something failed, or in our case, when at least one assertion failed.
Finding All the Specification Files
To find all the JavaScript specification files we can use a recursive function that traverses the directory indicated by the user as a parameter to the CLI. While we search, each file should be checked with the regular expression that we defined at the beginning of the program (/\.spec\.js$/
), which is going to match all file paths ending with .spec.js
.
function findSpecifications (dir, matchPattern) {
return fs.readdirSync(dir)
.map(filePath => path.join(dir, filePath))
.filter(filePath => matchPattern.test(filePath) && fs.statSync(filePath).isFile())
}
Our findSpecifications
function takes a target directory (dir
) and a regular expression that identifies the specification file (matchPattern
).
Getting the Contents of Each Specification
Since we are exporting template literals, getting the content and the evaluated assertions is simple, we have to import each file and when it gets imported all the assertions get evaluated automatically.
function getSpecifications (paths) {
return paths.map(path => require(path))
}
Using the map()
function we replace the path of the array with the content of the file using the node’s require
function.
Extracting the Assertions from the Text
At this point we have an array with the contents of each specification file and their assertions already evaluated. We use the turnstile indicator (|-
) to find all those assertions and extract them.
function getAssertions (specifications) {
return specifications.map(specification => ({
title: specification.split('\n\n', 1)[0].trim(),
assertions: specification.match(/^( |\t)*(\|-)(.|\n)*?\./gm).map(assertion => {
const assertionFragments = /(?:\|-) (\w*) ((?:.|\n)*)/.exec(assertion)
return {
value: assertionFragments[1],
description: assertionFragments[2].replace(/\n /, '')
}
})
}))
}
This function will return a similar array, but replacing the content of each specification with an object following this structure:
{
title: <String: Name of this particular specification>,
assertions: [
{
value: <Boolean: The result of the assertion>,
description: <String: The short description for the assertion>
}
]
}
The title
is set with the first line of the specification string. Then each assertion is stored as an array in the assertions
key. The value
represents the result of the assertion as a Boolean. We will use this value to know if the assertion was successful or not. Also, the description will be shown to the user as a way to identify which assertions succeeded and which ones failed. We use regular expressions in each case.
Logging Results
The array that we have built along the program now has a series of JavaScript specification files containing a list of found assertions with their result and description, so there is not much to do other than reporting the results to the user.
function logAssertions(assertionGroups) {
// Methods to log text with colors
const ansiColor = {
blue: text => console.log(`\x1b[1m\x1b[34m${text}\x1b[39m\x1b[22m`),
green: text => console.log(`\x1b[32m ✔ ${text}\x1b[39m`),
red: text => console.log(`\x1b[31m ✖ ${text}\x1b[39m`)
}
// Log the results
assertionGroups.forEach(group => {
ansiColor.blue(group.title)
group.assertions.forEach(assertion => {
assertion.value === 'true'
? ansiColor.green(assertion.description)
: ansiColor.red(assertion.description)
})
})
console.log('\n')
}
We can format our input with colors depending on the results. To show colors on the terminal we need to add ANSI escape codes. To simplify their usage in the next block we have saved each color as methods of an ansiColor
object.
First we want to show the title of the specification, remember that we are using the first dimension of the array for each specification, which we have named it as a group
(of assertions.) Then we log all the assertions depending on their value using their respective color: green for assertions that evaluated as true
and red for assertions that had another value. Note the comparison, we are checking for true
, as a string, since we are receiving strings from each file.
Checking Results
Finally, the last step is to check if all the tests were successful or not.
function checkAssertions (assertionGroups) {
return assertionGroups.some(
group => group.assertions.some(assertion => assertion.value === 'false')
) ? 1 : 0
}
We check every assertion group (specification) to see if at least one value is '
false
'
using the some()
method of Array
. We have nested two of them because we have a two dimensional array.
Running Our Program
At this point our CLI should be ready to run some JavaScript specifications and see if assertions are picked up and evaluated. In a test
directory you can copy the specification example from the beginning of this article, and paste the following command inside your package.json
file:
"scripts": {
"test": "node index.js test"
}
… where test
is the name of the directory where you have included the sample specification file.
When running the npm test
command, you should see the results with their respective colors.
Last Words
We have implemented a very simple but useful command-line program that can help us to make better software. There are some lessons that we can gain from this:
- Software can be simple and useful at the same time.
- We can build our own tools if we want something different, there is no reason to conform.
- Software is more than “making it work" but also about communicating ideas.
- Some times we can improve something just by changing the point of view. In this case, the format of the specification files: just a simple string!
An example workflow for this program, would be to place one .spec.js
file per module in your projects, describing in detail the intended functionality and properties that the program should have in form of assertions. You can sketch the idea of a new project in this way, and continuously improve until all the assertions have passed.
You can find the source code used in this article here.
Top comments (0)