A TDD implementation of Node.js Path module

#test-driven-development #path #nodejs

How many TDD practitioners does it take to change a light bulb? They don’t change the bulb; they refactor the darkness!

Introduction

I have asked ChatGPT to tell me some jokes about test-driven development (TDD) and out of 12 generated answers none included unicorns. That’s a bit of a disappointment considering how similar they are. Everyone is a big advocate of TDD and how great it is but I am yet to find a team that practices it consistently.

Ironically, I’ve chosen to join the advocates group by documenting a step-by-step TDD implementation of the Node.js Path module. But before we dive in, you might be wondering, ‘What is test-driven development?’ I have asked ChatGPT to answer this question for you, and here is the result:

Test-Driven Development (TDD) is a software development approach in which tests
are written before the actual code implementation. The TDD process typically
follows these steps:

1. **Write a Test**: Before writing any code, a developer creates a test that
defines a specific function or improvement of the software.

2. **Run the Test**: The newly created test is executed, and it should fail
because the corresponding code to fulfill the requirements hasn't been
implemented yet.

3. **Write Code**: The developer writes the minimum amount of code necessary to
pass the test. The primary goal is to make the test pass, not to create a
fully-featured solution.

4. **Run Tests**: After writing the code, all tests, including the new one, are
executed to ensure that the recent changes haven't broken any existing
functionality.

5. **Refactor Code**: If necessary, the code is refactored to improve its
structure, readability, or efficiency. At each step, the tests are rerun to make
sure that the changes haven't introduced errors.

6. **Repeat**: Steps 1-5 are repeated iteratively for each new piece of
functionality.

TDD is designed to promote a more robust and maintainable codebase by ensuring
that every piece of code has corresponding tests and that new features or
changes do not negatively impact existing functionality.

Pretty straight forward, right? TDD not only fosters a more robust and maintainable codebase but also mitigates the impact of “implementation bias” (status quo bias). This cognitive bias occurs when a programmer is influenced by existing code while crafting a new unit of code.

Consider this scenario: I’ve created a function tasked with parsing and processing an array of strings (we will actually write that later on in the path.resolve implementation). If I initially write the code without accounting for certain corner cases, chances are high that I’ll overlook those same scenarios when crafting the corresponding tests. Also, another programmer aiming to enhance code coverage may be willing to write unit tests for my implementation. Their focus could be on ensuring each statement is executed at least once, potentially achieving full unit coverage without a comprehensive understanding of the implementation details. Consequently, they might overlook the same corner cases that eluded me as the original author.

Additionally, having a test-first approach will ensure less coupling between your components, and a better design of public interfaces.

Project setup

The project is configured as a basic JavaScript module without any build steps. The initial commit introduces the necessary GitHub workflow to execute the pipeline, establishes a straightforward test setup utilizing the built-in Node.js test runner, and uses a simple forwarding mechanism for the node:path module (as a starting point):

// lib/b-path.js
import path from "node:path";

export default path;

The TDD approach

We are now ready to add our first tests and make sure they fail by swapping our forwarding implementation with an empty one. Here is how that looks like:

// tests/b-path.test.js
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import bPath from "../lib/b-path.js";

describe("b-path", () => {
  describe("POSIX", () => {
    it("path.delimiter should provide the path delimiter", () => {
      assert.strictEqual(bPath.delimiter, ":");
    });

    it("path.sep should provide the path separator", () => {
      assert.strictEqual(bPath.sep, "/");
    });
  });
});
// lib/b-path.js
const posix = {
  posix: null,
  win32: null,
};

posix.posix = posix;

export default posix;

Note that we will only focus on the POSIX implementation and completely ignore the Win32 API. With that in mind, we can now move to Step 3 and write the actual code, making the tests pass. This is as simple as adding the two missing properties to our posix object.

 
 
+
+
 
 
 
// lib/b-path.js
const posix = {
  delimiter: ":",
  sep: "/",
  posix: null,
  win32: null,
};

path.isAbsolute(path)

Moving on to path.isAbsolute, we’ll once again start with an empty implementation and write some tests based on the official documentation.

 
 
+
+
 
 
 
 
 
// lib/b-path.js
const posix = {
  isAbsolute: () => {},

  delimiter: ":",
  sep: "/",
  posix: null,
  win32: null,
};

 
 
 
 
 
 
 
 
 
 
 
 
+
+
+
+
+
+
+
+
+
+
+
+
+
+
 
 
// tests/b-path.test.js
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import bPath from "../lib/b-path.js";
describe("b-path", () => {
  describe("POSIX", () => {
    it("path.delimiter should provide the path delimiter", () => {
      assert.strictEqual(bPath.delimiter, ":");
    });
    it("path.sep should provide the path separator", () => {
      assert.strictEqual(bPath.sep, "/");
    });

    // input | expected result
    [
      ["", false],
      [".", false],
      ["foo/", false],
      ["foo.bar", false],
      ["/foo/bar", true],
      ["/baz/..", true],
    ].forEach(([input, result]) => {
      it(`path.isAbsolute("${input}") should return '${result}'`, () => {
        assert.strictEqual(bPath.isAbsolute(input), result);
      });
    });
  });
});

Step 3 rightfully emphasizes that “the primary objective is to ensure the test passes, rather than creating a fully-featured solution”. As you can see, I haven’t written any tests to address scenarios where the input is not a string and we must raise a TypeError. We can incorporate those tests a bit later, but for now, let’s focus on making our existing tests pass.

 
 
+
+
+
+
+
+
+
+
 
 
 
 
 
 
// lib/b-path.js
const posix = {
  /**
   * The isAbsolute() method determines if input path is an absolute path.
   * @param {string} path - input path
   * @returns {boolean}
   */
  isAbsolute: (path) => {
    return !!path && path.charCodeAt(0) === posix.sep.charCodeAt(0);
  },

  delimiter: ":",
  sep: "/",
  posix: null,
  win32: null,
};

Running all the tests (Step 4) will now output the following results:

> b-path@0.0.0 test
> node --test tests

 b-path
 POSIX
 path.delimiter should provide the path delimiter (0.745045ms)
 path.sep should provide the path separator (0.164463ms)
 path.isAbsolute("") should return 'false' (0.186978ms)
 path.isAbsolute(".") should return 'false' (0.125469ms)
 path.isAbsolute("foo/") should return 'false' (0.250108ms)
 path.isAbsolute("foo.bar") should return 'false' (0.204638ms)
 path.isAbsolute("/foo/bar") should return 'true' (1.390664ms)
 path.isAbsolute("/baz/..") should return 'true' (0.16196ms)
 POSIX (4.772938ms)

 b-path (6.41931ms)

 tests 8
 suites 2
 pass 8
 fail 0
 cancelled 0
 skipped 0
 todo 0
 duration_ms 104.492059

The not-so-happy path

As mentioned earlier, our current code and tests only cover the “happy path” where the input is provided as expected. Now, let’s address the “not-so-happy path” and consider various input types. As always, we’ll start with the tests first.

 
 
 
 
 
 
 
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
 
 
// tests/b-path.test.js
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import bPath from "../lib/b-path.js";
describe("b-path", () => {
  describe("POSIX", () => {
    // ...

    [undefined, null, NaN, 42, BigInt(42), {}, [], Symbol(), false].forEach(
      (input) => {
        it(`path.isAbsolute should throw TypeError if input type is ${typeof input}`, () => {
          assert.throws(
            () => {
              bPath.isAbsolute(input);
            },
            {
              name: "TypeError",
              message: `The "path" argument must be of type string. Received ${typeof input}`,
            }
          );
        });
      }
    );
  });
});

And then proceed with the actual implementation.

 
+
+
+
+
+
+
+
+
+
+
+
+
+
+
 
 
 
 
 
 
 
+
 
 
 
 
 
// lib/b-path.js
/**
 * The assertIsString() function verifies whether the input is a string,
 * throwing a TypeError otherwise.
 * @param {*} input
 * @throws {TypeError}
 */
function assertIsString(input) {
  if (typeof input !== "string") {
    throw new TypeError(
      `The "path" argument must be of type string. Received ${typeof input}`
    );
  }
}

const posix = {
  /**
   * The isAbsolute() method determines if input path is an absolute path.
   * @param {string} path - input path
   * @returns {boolean}
   */
  isAbsolute: (path) => {
    assertIsString(path);
    return !!path && path.charCodeAt(0) === posix.sep.charCodeAt(0);
  },

  // ...
};

path.format(pathObject)

We continue our reverse engineering process with path.format. This method takes a pathObject as input and returns a path string based on the rules specified in the docs. We’ll start with the “happy path” where the input is an object and we’ll deal with the rest later. Once we have completed our implementation, we can swap it back with the ‘forwarding mechanism’ from the project setup and check that we have the right functionality.

Documenting every single step here would occupy too much space, instead, I have tagged the commits with pre-release tags to get a better overview. The very first version is somehow naive but it does cover most of the cases, which is a great starting point.

We should now handle the following scenario: “If only root is provided or dir is equal to root then the platform separator will not be included”. To do so, we need to make some changes to our tests.

 
 
 
 
-
+
+
+
 
-
+
 
-
+
 
-
+
 
 
 
 
 
 
 
// input | expected result
[
  [{}, ""],
  [{ root: "/root" }, "/root"],
  [{ root: "/root", name: "file", ext: ".txt" }, "/root/file.txt"],
  // only root is present so no separator is included
  [{ root: "/root", name: "file", ext: ".txt" }, "/rootfile.txt"],
  [{ root: "/root/", name: "file", ext: ".txt" }, "/root/file.txt"],
  // should add the extension "." if missing
  [{ root: "/root", name: "file", ext: "txt" }, "/root/file.txt"],
  [{ root: "/root/", name: "file", ext: "txt" }, "/root/file.txt"],
  // should ignore 'name' and 'ext' if 'base' is present
  [{ root: "/root", base: "base.sh", name: "file", ext: ".txt" }, "/root/base.sh"],
  [{ root: "/root/", base: "base.sh", name: "file", ext: ".txt" }, "/root/base.sh"],
  // should ignore 'root' if 'dir' is present
  [{ root: "/root", dir: "/dir", base: "file.txt"}, "/dir/file.txt"],
  [{ root: "/root/", dir: "/dir", base: "file.txt"}, "/dir/file.txt"],
].forEach(([input, result]) => {
  it(`path.format(${JSON.stringify(
    input
  )}) should return '${result}'`, () => {
    assert.strictEqual(bPath.format(input), result);
  });
});

We may attempt to correct our implementation by adjusting the return statement to return result.join("");. However, this adjustment would lead to a failure in our dir test:

 failing tests:

 path.format({"root":"/root/","dir":"/dir","base":"file.txt"}) should return '/dir/file.txt' (2.433298ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  + actual - expected

  + '/dirfile.txt'
  - '/dir/file.txt'
         ^
      at TestContext.<anonymous> (file:///home/elvis/eadomnica/b-path/tests/b-path.test.js:62:16)
      at Test.runInAsyncScope (node:async_hooks:206:9)
      at Test.run (node:internal/test_runner/test:580:25)
      at Suite.processPendingSubtests (node:internal/test_runner/test:325:18)
      at Test.postRun (node:internal/test_runner/test:649:19)
      at Test.run (node:internal/test_runner/test:608:10)
      at async Suite.processPendingSubtests (node:internal/test_runner/test:325:7) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: '/dirfile.txt',
    expected: '/dir/file.txt',
    operator: 'strictEqual'
  }

We can address this issue by ensuring that dir always includes a trailing slash, much like how we handled the absence of a dot in the extension. Here’s what our updated solution would look like:

/**
 * The path.format() method returns a path string from an object.
 * This is the opposite of `path.parse()`.
 * @param {object} pathObject
 * @param {string} pathObject.root
 * @param {string} pathObject.dir
 * @param {string} pathObject.base
 * @param {string} pathObject.name
 * @param {string} pathObject.ext
 * @returns {string}
 */
format: (pathObject) => {
  const result = [];

  const root = pathObject.root || "";
  let dir = pathObject.dir || "";
  if (dir && dir.charCodeAt(dir.length - 1) !== posix.sep.charCodeAt(0)) {
    dir = `${dir}${posix.sep}`;
  }
  const base = pathObject.base || "";
  const name = pathObject.name || "";
  let ext = pathObject.ext || "";
  if (ext && ext.charCodeAt(0) !== ".".charCodeAt(0)) {
    ext = `.${ext}`;
  }

  const dirName = dir || root;
  const baseName = base || `${name}${ext}`;

  if (dirName) result.push(dirName);
  if (baseName) result.push(baseName);

  return result.join("");
},

We’ll now proceed to substitute our current implementation with the ‘forwarding mechanism’ to confirm its correctness. Please note that the assertIsString function doesn’t yield the same TypeError message, so we’ll need to comment out those specific tests. Additionally, I’ll take this opportunity to write more tests and cover some other corner cases.

This is slightly unexpected, only Node.js v20.x tests are passing. Nonetheless, I find it okay since v20.x transitioned to a Long Term Support version last October.

As for the extra tests, I went ahead and included a few more test cases. What caught me off guard was that a trailing slash is added to the dir property even if it already exists. The only scenario where this doesn’t occur is if root is equal to dir, as specified in the docs. I had anticipated that the resulting path would be normalized but it doesn’t seem so. Consequently, I had to modify my implementation to match the original behavior by making the following change:

 
 
-
+
 
 
 
const root = pathObject.root || "";
let dir = pathObject.dir || "";
if (dir && dir.charCodeAt(dir.length - 1) !== posix.sep.charCodeAt(0))
if (dir && dir !== root) {
  dir = `${dir}${posix.sep}`;
}
const base = pathObject.base || "";

The full diff of the TDD approach for path.format (including the input check) can be seen here. Keep in mind that the commit at the bottom of the page is in fact our top commit.

path.normalize

Before we continue with path.parse and friends (path.basename, path.dirname, path.extname), let’s address one of the elephants in the room: path.normalize.

We can start by modifying the input sanity check test from path.isAbsolute to include our new method. With that out of the way, we can now think of the best approach in handling such a complex function.

I must admit that I have already done this exercise last year, but I have deleted my repository, so I will have to do this all over again. If I remember correctly, the approach I took was to split the string by the path.sep and use a reduce function to compute the normalized path. While this will work, I cannot stop and think about the amount of intermediate objects created and the time spent traversing the input multiple times. Can we do the normalization in one go? Well, I guess we can, with the good-old for loop.

Alright! The implementation so far is not as crazy as I would have thought, but there are definitely some unhandled corner cases that I will be adding later. At this point, there are a total of 20 commits, one of which includes a small refactoring. This is by far the biggest advantage of test-driven development: you can refactor your code as you see fit while having the reassurance that it will still function correctly. And, on top of that, we preserved 100% code coverage.

After adding the promised corner cases, there was a need for some small adjustments, mainly around how to handle navigating up from the top directory. But wait, what about some paths that make absolutely no sense? Something like:

["...", "..."], // makes no sense
[".../.", "..."], // same
[".../..", "."], // same

We are reverse engineering the existing behavior so I guess I will have to go ahead and fix those as well. Even with such a non-sense corner case, our final implementation doesn’t look very complex. Of course, as the author of the code I am extremely biased on this. And actually, as I pasted the code here, I realized I can do one more improvement. This is the final final version, I promise (unless you find a bug).

/**
 * Normalize a string path, reducing '..' and '.' parts.
 * When multiple slashes are found, they're replaced by a single one;
 * when the path contains a trailing slash, it is preserved.
 *
 * @param path string path to normalize.
 * @throws {TypeError} if `path` is not a string.
 */
normalize: (path) => {
  assertIsString(path);

  if (path.length === 0) {
    return ".";
  }

  if (path.length === 1) {
    return path;
  }

  const hasTrailingSep = path.charAt(path.length - 1) === posix.sep;
  const absolute = posix.isAbsolute(path);
  const fragments = [];
  let word = "";
  let dots = 0;

  const handleFragment = () => {
    if (dots > 2) {
      fragments.push(Array(dots).fill(".").join(""));
    }

    if (dots === 2) {
      if (fragments.length && fragments[fragments.length - 1] !== "..") {
        // navigate up but don't pop an existing ".."
        fragments.pop();
      } else if (!absolute) {
        // top reached and relative path,
        // absolute path is handled in the if (fragments.length === 0) below
        fragments.push("..");
      }
    }

    if (word.length) {
      fragments.push(word);
    }
  };

  for (let i = 0; i < path.length; i += 1) {
    // duplicated sep, skip
    if (
      path.charAt(i) === posix.sep &&
      i - 1 >= 0 &&
      path.charAt(i - 1) === posix.sep
    ) {
      continue;
    }

    if (path.charAt(i) === ".") {
      dots += 1;
      continue;
    }

    if (path.charAt(i) === posix.sep) {
      handleFragment();

      word = "";
      dots = 0;
      continue;
    }

    word += path.charAt(i);
  }

  handleFragment();

  if (fragments.length === 0) {
    if (absolute) {
      return posix.sep;
    }

    return hasTrailingSep ? "./" : ".";
  }

  let result = fragments.join(posix.sep);
  if (hasTrailingSep) {
    result += posix.sep;
  }

  return result;
},

For comparison, I have found a package that contains the posix-only implementation called path-browserify, which is an extract from Node.js v8.11.1 codebase. This is how that looks like:

// Resolves . and .. elements in a path with directory names
function normalizeStringPosix(path, allowAboveRoot) {
  var res = '';
  var lastSegmentLength = 0;
  var lastSlash = -1;
  var dots = 0;
  var code;
  for (var i = 0; i <= path.length; ++i) {
    if (i < path.length)
      code = path.charCodeAt(i);
    else if (code === 47 /*/*/)
      break;
    else
      code = 47 /*/*/;
    if (code === 47 /*/*/) {
      if (lastSlash === i - 1 || dots === 1) {
        // NOOP
      } else if (lastSlash !== i - 1 && dots === 2) {
        if (res.length < 2 || lastSegmentLength !== 2 || res.charCodeAt(res.length - 1) !== 46 /*.*/ || res.charCodeAt(res.length - 2) !== 46 /*.*/) {
          if (res.length > 2) {
            var lastSlashIndex = res.lastIndexOf('/');
            if (lastSlashIndex !== res.length - 1) {
              if (lastSlashIndex === -1) {
                res = '';
                lastSegmentLength = 0;
              } else {
                res = res.slice(0, lastSlashIndex);
                lastSegmentLength = res.length - 1 - res.lastIndexOf('/');
              }
              lastSlash = i;
              dots = 0;
              continue;
            }
          } else if (res.length === 2 || res.length === 1) {
            res = '';
            lastSegmentLength = 0;
            lastSlash = i;
            dots = 0;
            continue;
          }
        }
        if (allowAboveRoot) {
          if (res.length > 0)
            res += '/..';
          else
            res = '..';
          lastSegmentLength = 2;
        }
      } else {
        if (res.length > 0)
          res += '/' + path.slice(lastSlash + 1, i);
        else
          res = path.slice(lastSlash + 1, i);
        lastSegmentLength = i - lastSlash - 1;
      }
      lastSlash = i;
      dots = 0;
    } else if (code === 46 /*.*/ && dots !== -1) {
      ++dots;
    } else {
      dots = -1;
    }
  }
  return res;
}

//...

function normalize(path) {
  assertPath(path);

  if (path.length === 0) return '.';

  var isAbsolute = path.charCodeAt(0) === 47 /*/*/;
  var trailingSeparator = path.charCodeAt(path.length - 1) === 47 /*/*/;

  // Normalize the path
  path = normalizeStringPosix(path, !isAbsolute);

  if (path.length === 0 && !isAbsolute) path = '.';
  if (path.length > 0 && trailingSeparator) path += '/';

  if (isAbsolute) return '/' + path;
  return path;
},

You can be the judge of which version is easier to grasp.

path.parse(path)

To be continued…