The “_test” convention

White-box testing for JavaScript modules

Alex Ewerlöf (moved to substack)
codeburst

--

Photo by Markus Spiske from Pexels

It’s good practice to keep the number of lines in a function limited (here’s Martin Fowler elaborating the point and here’s an ESlint rule to enforce it). To break a long function, we need to identify the parts that are abstract enough to be extracted to new and smaller functions.

  • We’ll end up with more functions which increases the cyclomatic complexity
  • But these smaller functions are easier to read and test.
  • Also these smaller functions are easier to reuse in other contexts.

Each software (whether it’s a library or a stand alone app or CLI) handles various use-cases.

Complex functions often lead to complex tests

If we think of various use-cases that a software handles in terms of execution paths between logical units of work, there are two main strategy to test them:

Black-box testing

Black-box testing is a method of software testing that examines the functionality of an application without peering into its internal structures or workings (wikipedia).

This approach:

  • Lead to more complex tests which are harder to write and read (often involving mocks or emulating adjacent systems)
  • Makes refactoring much easier (as long as the new code adheres to the tested public contracts)

White-box testing

White-box testing (also known as clear box testing, glass box testing, transparent box testing, and structural testing) is a method of software testing that tests internal structures or workings of an application (wikipedia)

This approach:

  • Leads to smaller unit tests (specially if you have a lot of pure functions) which are easier to reason about and debug
  • Fixating on the internal functionality of a component with tests makes future refactoring harder because the tests need to be updated too (refactoring is mainly concerned about changing the internals while maintaining the external API surface the same). The internal functions are usually more prone to refactoring which means their tests bear the cost of refactoring as well.

White-box testing on its own does not guarantee the quality of the software. The individually tested units of work need to be tested together (integration tests).

Think of your code as a car. It needs to be taken on a test drive before it leaves the factory. One doesn’t need to bother with driving if the motor, transmission, airbag, etc. haven’t gone through quality control.

Some argue that testing the internals is a code-smell. But that’s exactly how most modern software is tested. Take a Node Express web server for example:

  • It has some business specific code that are unique to the server, which shall be tested
  • But also it uses hundreds of other packages for setting up the web server, talking to the database, monitoring, logging, etc. Express itself is comprised of tens of other modules. In all actuality, we prefer to use modules that are tested.

So let’s think about the user space code as nested blocks of code:

  • The bigger blocks contain the public interfaces which handle the business use-cases.
  • The bigger blocks are composed of smaller blocks which handle more granular units of work encapsulating possibly reusable code.
Functions are represented as boxes here. Nested box represents a dependency

Let’s demonstrate this with a very simple example:

We are writing a JavaScript library that has only one function. It takes an array of shape objects and returns the total sum of their areas. Each shape object has a type property which can be 'circle'or 'square' and other props that are needed for calculating the area.

Alternative 1

Don’t export the internal functions but expose them through an exported object called _test. This way we make it clear that these functions are not part of the API surface and clarify our intent for testing.

Think of the _test convention as that little “service” or “inspection” port that is available behind some electronics.

The SERVICE port behind my Samsung TV

Some may find the _test unusual but it’s intentional the same way that “SERVICE” port is clearly marked.

Using this alternative, our example looks like this:

The test file looks like this:

If you want to play with the code, it’s available on Github.

Alternative 2

An alternative pattern for libraries:

  • Put the internals in another module (let’s call it “internal module”)
  • Test the internal module
  • Don’t export that internal module as the API surface through the library’s main entry point
The internal functions get their own file that is not exposed through the main entry point of the library

The downside of this way of grouping functions in different files is that they lose the context. If these functions are only used in one particular file, then one way to signal this dependency is to use a descriptive file name. For example, if the main file is called shape-area.js then these internal functions can reside in shape-area-utils.js.

Here’s how the library entry point exports only the public API surface:

The file containing the public API is tiny:

All the internal functions go to a separate file and exported directly instead of using_test:

Together with the tests, this alternative leads to 5 files (check out the demo repo on Github repo) compared to 2 files using the _test convention but it looks less surprising to new developers (POLA).

Alternative 3

It’s a simplified variation of the second alternative:

  • Keep the public and internal functions in one module and export & test them.
  • Only export the public function explicitly from the library’s entry point.
The public functions are explicitly exported from the main entry point

The index.js file looks like this:

The public and internal functions are all in the same file (same as the _test approach):

Together with the tests, we end up with three files (in the demo repo, there is an extra test for the index.js to guarantee the existence of thesumShapeAreas function as a contract but that’s optional).

Which alternative is the best?

Let’s recap:

  • Alternative 1: _test is a bit of an awkward name, but it clarifies the intention. It requires more work to export and the internals and imports them in the tests, but it’s very explicit.
  • Alternative 2: structures public and internal files in separate files. You end up with more files but you know where to put a function based on how it is supposed to be exposed to the user of the library.
  • Alternative 3: has elements from both other alternatives but leads to fewer files. The public API surface needs to be exported explicitly which might be daunting for larger API surfaces. But it has the added benefit of keeping the relevant logs (the public function and the internal functions that it uses) in the same file.

Both alternatives 1 and 2 make it easy to share internal functions across multiple publicly facing functions. However, it is not instantly clear which functions are part of the API surface and which ones are internal and purely exported for testing purposes. It’s quite possible to accidentally export an internal function that other code may come to depend upon. In that case, refactoring is going to be even harder.

I personally prefer the _test approach (alternative 1) due to its explicit nature. Using this approach the internal functions stay in the same file which gives them context (a common anti-pattern is to move all such functions to utils).

The code for all three alternatives is available on Github. You can pick the one that makes sense to you.

Did you like what you read? Follow me here or on LinkedIn. I write about technical leadership and web architecture. If you want to translate or republish this article, here’s a quick guide.

--

--