A study of Test Driven Development and Functional Programming in TypeScript

From class-based IoC to functional TDD? - A journey

ยท

12 min read

Introduction

I come from an object-oriented programming (OOP) background - it was the hot stuff in my formative years of learning to program - driven by C++ and Java. Embracing unit testing meant embracing Inversion of Control (IoC) and Dependency Injection (DI). This was the way.

As my language of choice switched to TypeScript, and passing data as objects defined by interfaces passed through HTTP APIs, real OOP classes fell away but I continued to use classes for DI. Business logic was in service classes, which were really just collections of functions with shared DI in the constructor. Unit tests mocked out all those dependencies, and with pride my coverage often hit 100%. I tested my code against unit tests first, so that everything usually worked upon first deployment. I largely forgot how to use the IDE's debugger.

All was good.

I had heard the fans of Test Driven Development (TDD) exclaim the wonders of it, but figured my test before deploy approach basically did that. (Turns out, not really.)

I had heard the fans of functional programming (FP) exclaim their superiority over OOP. But hey, I'm just using classes to organize my functions for DI - so I was basically functional. (Yes, but no.)

I did not see the connection between these. I didn't fully understand either one, and I knew that. Occasionally I'd read a blog article about TDD or FP to try to understand the excitement. I wanted to find these techniques exciting! But everything I read was so basic that I couldn't see how to apply it to real problems, and they didn't show how to connect the concepts. Or worse perhaps, TDD was completely misrepresented and looked horrible because doing it wrong is horrible.

I'd think: functional is great for pure functions, but how do you test the orchestration that isn't pure?

What really is Test Driven Development?

Robert C. Martin (Uncle Bob) covers a lot of ground about software development practices in his talk about Expecting Professionalism and specifically TDD here. (Watch these.)

The analogy to the accounting practice of double-entry bookkeeping especially resonated for me. Double-entry bookkeeping is the discipline of keeping a transaction ledger for each account. When you subtract from one account, it must be added to another - immediately. Often this other "account" isn't one in the traditional sense, it is just a means of tracking and checking your work. It's a checksum. Subtract here; add there; compare the totals. If you make a mistake, the bookkeeper immediately knows.

We often write all the code, and then write all our tests to check it. Bookkeepers don't. They subtract an entry here, and add it there. It's an atomic operation. Why? Because if they did all one and then the other and make a mistake, the checksum at the end is simply "it doesn't match". Where is the mistake? It's somewhere in one of the many transactions they added. That's painful, and entering all transactions atomicly is less so.

TDD is like double-entry bookkeeping. The rules are put in two places, atomically. Write test code, then write some production code. Checksum. Write some test code, write some production code. Checksum. It may sound painful, but if you introduce a bug you will know it immediately. It's the ultimate linting tool. How painful is this compared to a bug in production? Once you get used to it, not at all. This is the #1 TDD promise: Bug free code! (Caveat: You have to understand the desired behavior, of course. Otherwise you'll write the wrong behavior twice.)

Here's the #2 TDD promise: Your code will be cleaner, easier to maintain, and self documenting. Watch the videos and find the arguments for this promise.

If Uncle Bob and I finally convinced you that Test Driven Development (TDD) is amazing, follow it up with Ian Cooper's dedicated talk on the topic: TDD, Where Did It All Go Wrong (Yes, this is actually pro TDD! He addresses where people get it wrong.)

Next in my research, I discovered this guide. No longer a simplistic example. TDD and FP rolled up in an example that is big enough to hit real challenges, and explained progressively rather than just an end result. But.... well... what's with all the factory functions? Everything is a factory now. ๐Ÿ˜ฌ

That last article linked to this original guide to testing without mocks . Here's another concept that ties in neatly with TDD, but this one is using classes instead of functions. The classes though, still involve factories. Also, the examples are showing React code - reminding me that React components themselves use the factory pattern, whether functional or class-based. I don't know why he wants to go entirely without mocks (to the point of running a fake server in-process - just mock it already!), but the takeaway is that mocking (and faking) can be a final option rather than the first go-to choice.

What is Functional Programming?

Go full functional, or use classes? Either work in JavaScript, to an extent, but that's something standing out in these examples: they are both JavaScript not TypeScript. To some extent, I can see that powerful argument for strong typing is weakened when doing proper TDD: failures happen by default. That makes me think maybe I can allow implicit any (turn off that eslint rule) and not define types for everything, but there are still benefit to strong types. Are types more difficult with FP? There does appear to be a correlation between FP and going full dynamic typing. ๐Ÿง

When to use class vs function? This article about applying CleanCode to TypeScript helped me realize it is not an either-or choice. Looking at the patterns, I realize I can lean functional, but enjoy class and real OOP goodness where it makes sense. In short: classes are for object-oriented logic, modules are for grouping related functions.

But wait, you a FP practitioner shouts! That isn't what functional programming is about! Yes, Uncle Bob has set me straight in his talk about Functional Programming. Actual FP is about being stateless. A Pure Function is one with no side effects - given the same input it will always have the same output. Pure. Testable. Stateless. Often recursive. In fact, FP purists tell us that functional has no state, even local variables, and no loops; recursion takes the place of both and for this not to be a poor developer experience you need a language designed for this. (Lisp, Haskell, Clojure, ...) Simply using just functions instead of classes doesn't automatically mean FP. Really, applications can't be entirely FP; only functions can be pure. An application with no state is just a big function and has pretty limited use. Also, we're talking about JavaScript here, which not a real FP language, but rather a hybrid. Functions don't have to be pure. We even have classes (functions plus state).

Can we do FP in JavaScript? Yes, the Array functions like map and filter are functional. The popular RxJS library is also functional, and if you have worked with it you have an idea of how complicated functional can get. The big dog in TypeScript functional is the fp-ts. Even more than RxJS, it requires massive buy-in by the entire application team to learn and use this approach.

So am I looking at real FP, or just not using the class keyword?

So... TDD?

I'm buying what TDD is selling. It sounds doable, and individuals can do it even on existing projects and without forcing every other developer of an application, now and in the future, to understand and use it. (Indeed, I have started doing this!)

Not sure how to get actually get going?

Here's the Three Rules of TDD:

  1. Write production code only to pass a failing unit test.
  2. Write no more of a unit test than sufficient to fail (compilation failures are failures).
  3. Write no more production code than necessary to pass the one failing unit test.

Here's some Live Stream Examples doing TDD in JavaScript.

So... FP?

For me, using TypeScript, no.

Kent C. Dodd's arguments for a functional approach is worth a read. I don't buy into the "this is too complicated" argument, I think he's just allergic to to the class keyword, but stateless programming with pure functions instead of OOP has some merit. Not only does it make testing easy, but it plays real nice with using objects defined as interfaces, instead of classes. One might implement the builder pattern with a class though, for some logical transformations.

Should we scope functions at the module (file) level instead of class? Here's how we might do Inversion of Control (IoC) without any fancy Dependency Injection (DI) magic:

function a() {
}

function fakeA() {
}

function b() {
}

function fakeB() {
}

export function moduleFactory(
    otherModuleDependency = otherModuleFactory(),
) {
    return {a, b};
}

export function fakeModuleFactory() {
    return {a: fakeA, b: fakeB};
}

Much FP code is actually allergic not only to the class keyword, but even function! So we might export our factory function as the module default like this:

import UserRepository from "user.repository";

export default (repo = UserRepository()) => {
    return {
        a: () => {
        },
        b: () => {
        },
    };
}

To me, this is getting less readable than a nice export class UserLogic with constructor DI.

dvlsg on Reddit perhaps sums up the functions (closure) vs class debate best:

Some people just prefer using closures to using state on classes, and don't like using 'this'. That's really it. It's a valid opinion, but it is just an opinion.

Then there's Dax Raad on Twitter:

a fundamental tradeoff that no one talks about with functional programming is the more you use it, the more annoying you become

FP is a big shift. Going all the way with it will make your code unmaintainable by most of the programming community, and so requires total buy-in. Choosing to do FP in a language it isn't native to feels even worse than trying convincing folks to switch to a proper FP language. Arguments can be made to do this, but it is a huge leap.

So... OOP?

Functional and object-oriented aren't black-and-white polar opposite options. Let's explore a little.

We can certainly borrow much from FP, just as JavaScript ES2015 did when it introduced the new Array functions like map and reduce. Tucking business logic into pure functions and immutable classes adopts some FP concepts without going too deep.

The function-only and "no mock" (which turns out to just be hand-crafted fakes) approach for DI and testing just doesn't appear to buy anything. Class constructors are replaced with factories. Here is a good example. Whether highly related functions are grouped together as returned by one factory, or grouped together as one class, doesn't make any difference. I have certainly had trouble with classes growing large, with all the logic and orchestration for a certain thing crammed together, but that can happen with functions and modules too.

The solution is not to group functions by the thing they operate on, but by dependencies they share (high cohesion) and what they do (single responsibility). Instead of having a UserService class that grows huge, have a class per use-case that operates on a user. That keeps the classes small.

Below I'm using interfaces and a (pure) guard function from "domain/models", pure functions from "domain/logic" and "utils/assertions", and coordinating it all as a use case in a clean cohesive class.

import { isUserSignUpRequest, UserSignUpRequest, UserSignUpResponse } from "domain/models";
import { newUserFactory } from "domain/logic/user";
import { assertValidInput } from "utils/assertions";

// Optionally use DI magic to gain performance of singletons:
// @Injectable()
export class UserSignUpUseCase {
    // Dependencies defaulted - tests can provide mocks or fakes
    constructor(
        auth = new AuthService(),
        userRepo = new UserRepository(),
    ) {
    }

    async process(request: UserSignUpRequest): Promise<UserSignUpResponse> {
        this.validateRequest(request);
        const user = newUserFactory(request);
        await this.userRepo.put(user);
        return {user};
    }

    private validateRequest(request: UserSignUpRequest): void {
        this.auth.assertIsNotAuthenticated();
        assertValidInput(request, isUserSignUpRequest);
    }
}

These use-case classes can also be called "Controllers" in the traditional sense. That name is often used now for RESTful path handling though, so I find UseCase is clearer.

Could this be done with module scope exporting a factory function? Absolutely. Would it be as readable? I say no.

Rules to Follow?

This is my personal take and how I intend to proceed. Your research and background may lead you to a different conclusions.

  1. Use TDD, with circumstantial flexibility. (Don't be obsessive.)
  2. Use types and interfaces for APIs.
  3. Favor pure functions for business transformation logic - anything that takes a parameter or two, and returns a result or two without mutation or outside state.
  4. Use OOP where it has value - but the object must be self-contained so that state changes only impact that object instance.
  5. Use Dependency Injection (DI) with small classes that have a single responsibility and high cohesion.
    • These classes aren't OOP, just collections of functions and dependencies.
    • This can be done with modules and factory functions, but this is more effort for less readable syntax than a nice clean class; this syntactic sugar was added for good reason. Also, separating the function grouping name (class) from the file name feels more flexible.
  6. If a test fake is needed, export it along-side the real code so that it can be reused by any test that depend on it.

File structure

Wrapping all this up, how might I organize the source code?

.
โ””โ”€โ”€ src
    โ”œโ”€โ”€ domain
    โ”‚   โ”œโ”€โ”€ logic  # domain logic functions & object-oriented classes
    โ”‚   โ”‚   โ””โ”€โ”€ user.logic.ts
    โ”‚   โ””โ”€โ”€ models
    โ”‚       โ”œโ”€โ”€ index.ts
    โ”‚       โ””โ”€โ”€ user.ts
    โ”œโ”€โ”€ handlers
    โ”‚   โ””โ”€โ”€ user-sign-up.lambda.ts
    โ”œโ”€โ”€ external
    โ”‚   โ”œโ”€โ”€ dynamodb.service.ts
    โ”‚   โ””โ”€โ”€ repositories
    โ”‚       โ””โ”€โ”€ user.repository.ts
    โ””โ”€โ”€ use-cases
        โ””โ”€โ”€ user
            โ”œโ”€โ”€ user-sign-up.test.ts
            โ””โ”€โ”€ user-sign-up.ts

The entire domain, or just domain/models, might go into a separate package to be shared with client code - these models are your API contracts.

The handlers are the entry points into your code. You might have separate handlers for different environments such as one for AWS Lambda and another for a containerized cloud. This puts you on the road to Hexagonal Architecture. Here, user-sign-up.lambda.ts just deals with unpacking the request from API Gateway and Lambda, calling the use-case, and formatting the proper response back to Lambda and API Gateway.

The external directory is the right-side of your Hexagonal Architecture - the interfaces out to external sources and targets of state.

Finally, use-cases coordinate processing of individual requests and commands.

Conclusion

These explorations of techniques are fun, but sure do make for long blog posts! The relation between TDD and FP isn't as strong as I started off thinking, but figuring out how to make unit testing work with FP was highly relevant.

Let's sum this up:

  1. TDD gives you superpowers; embrace it!
  2. Real Functional Programming is only practical in a language made for it, but we can take lessons from FP and apply them in other languages.
  3. The class keyword is not poison, and is useful syntactic sugar beyond OOP.
ย