{The Art of Unit Testing}

Reading notes by Benjamin Fan

  • Breaking dependencies
  • Why use stubs
  • How to use stubs

Agenda

# CHAPTER 1

Breaking Dependencies

Dependency:

Services/components that you can't fully control in the SUT include loggers, file access, network interaction, and DB access.

Types of Dependencies

Source: The Art of Unit Testing p.62 Figure 3.1

# CHAPTER 1

Types of Dependencies

# CHAPTER 1
# CHAPTER 1
// in main.ts
import * as core from '@actions/core';
async function run() {
  const defaultBranch = await octokit.rest.repos.get({
    owner: targetOwner,
    repo: targetRepo,
    headers: {
      accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
    },
  });
  
  // use the defaultBranch to do something
}

Incoming dependency

# CHAPTER 2

Why use stubs

Breaks the incoming dependency.

# CHAPTER 2
import * as core from '@actions/core';
async function run() {
  const defaultBranch = await octokit.rest.repos.get({
    owner: targetOwner,
    repo: targetRepo,
    headers: {
      accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
    },
  });
  
  // use the defaultBranch to do something
}

What if you want to test without the internet?

What if you want to test without a GitHub API token?

Why use stubs

# CHAPTER 3

How to use stubs

There are three ways to adopt stubs in your unit tests. Some of these require more refactoring of your production code.

Three approaches to stubbing

Functional approaches

  • Function as parameter
  • Partial application (currying)
  • Factory functions
  • Constructor functions

1.

2.

Modular approach

  • Module injection

3.

Object-oriented approaches

  • Class constructor injection
  • Object as parameter (aka duck typing)
  • Common interface as parameter
# CHAPTER 3
# CHAPTER 3

Sample Code

async function filterByDate(branches: any, expiryDays: int) {
  const one_day = 1000 * 60 * 60 * 24;
  const latest_commit_date = new Date (
    branch.data.commit.commit.committer?.date ?? '',
  );
    const result = Math.round(
    (new Date() -
      latest_commit_date.setDate(
        latest_commit_date.getDate() + expiryDays,
      )) /
      one_day,
  );
  
  return result >= 0;
}

What if you want to test the expiry scenario?

# CHAPTER 3

Parameter injection

async function filterByDate(branches: any, expiryDays: int, present_date: Date) {
  const one_day = 1000 * 60 * 60 * 24;
  const latest_commit_date = new Date (
    branch.data.commit.commit.committer?.date ?? '',
  );
    const result = Math.round(
    (present_date -
      latest_commit_date.setDate(
        latest_commit_date.getDate() + expiryDays,
      )) /
      one_day,
  );
  
  return result >= 0;
}
it.each(branchDetail)('Branch is expiry', async branchDetail => {
  const present_date = new Date(Date.UTC(2021, 1, 1, 0, 00, 00));
  
  const result = await filterByDate(branchDetail, 10, present_data);
  
  expect(result).toBe(false);
})
# CHAPTER 3
async function filterByDate(branches: any, expiryDays: int, getDayFn: Function) {
  const one_day = 1000 * 60 * 60 * 24;
  const latest_commit_date = new Date (
    branch.data.commit.commit.committer?.date ?? '',
  );
    const present_date = getDayFn();
    const result = Math.round(
    (present_date -
      latest_commit_date.setDate(
        latest_commit_date.getDate() + expiryDays,
      )) /
      one_day,
  );
  
  return result >= 0;
}

Function as parameter

it.each(branchDetail)('Branch is expiry', async branchDetail => {
  const present_date = () => new Date(Date.UTC(2021, 1, 1, 0, 00, 00));
  
  const result = await filterByDate(branchDetail, 10, present_data);
  
  expect(result).toBe(false);
})
# CHAPTER 3

Injection via Partial application

async function filterByDate(branches: any, expiryDays: int) {
  return function(getDayFn: Function) {
    const one_day = 1000 * 60 * 60 * 24;
    const latest_commit_date = new Date (
      branch.data.commit.commit.committer?.date ?? '',
    );
    const present_date = getDayFn();
    const result = Math.round(
      (present_date -
       latest_commit_date.setDate(
        latest_commit_date.getDate() + expiryDays,
      )) /
      one_day,);
  return result >= 0;
}}
it.each(branchDetail)('Branch is expiry', async branchDetail => {
  const filterFn = await filterByDate(branchDetail, 10);
  const present_date = () => new Date(Date.UTC(2021, 1, 1, 0, 00, 00));
  const result = await filterFn(present_data);
  
  expect(result).toBe(false);
})
# CHAPTER 3
const moment = require('moment');
const SUNDAY = 0; const SATURDAY = 6;

const verifyPassword = (input, rules) => {
    const dayOfWeek = moment().day();
    if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
  // more code goes here...
  // return list of errors found..
  return [];
};

Modular injection

This is the sample code used here

This code will verify whether today is a weekend

# CHAPTER 3

Modular injection

const originalDependencies = {    
    moment: require(‘moment’),    
};
let dependencies = { ...originalDependencies };
const inject = (fakes) => {          
    Object.assign(dependencies, fakes);
    return function reset() {                    
        dependencies = { ...originalDependencies };
    }
};

const SUNDAY = 0; const SATURDAY = 6;
const verifyPassword = (input, rules) => {
  //...
  const dayOfWeek = dependencies.moment().day();
  if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
  //...
  return [];
}

module.exports = {
    SATURDAY,
    verifyPassword,
    inject
};
# CHAPTER 3

Modular injection

const { inject, verifyPassword, SATURDAY } = require('./password-verifier-
time00-modular');

const injectDate = (newDay) => {  
    const reset = inject({       
        moment: function () {
            //we're faking the moment.js module's API here.
            return {
                day: () => newDay
            }
        }
    });
    return reset;
};
describe('verifyPassword', () => {
    describe('when its the weekend', () => {
        it('throws an error', () => {
            const reset = injectDate(SATURDAY);   

            expect(() => verifyPassword('any input'))
                .toThrow("It's the weekend!");

            reset();   
        });
    });
});
# CHAPTER 3

OO constructor injection

class PasswordVerifier {
    constructor(rules, dayOfWeekFn) {
        this.rules = rules;
        this.dayOfWeek = dayOfWeekFn;
    }

    verify(input) {
        if ([SATURDAY, SUNDAY].includes(this.dayOfWeek())) {
            throw new Error("It's the weekend!");
        }
        const errors = [];
        //more code goes here..
        return errors;
    };
}
test('class constructor: on weekends, throws exception', () => {
    const alwaysSunday = () => SUNDAY;
    const verifier = new PasswordVerifier([], alwaysSunday);

    expect(() => verifier.verify('anything'))
        .toThrow("It's the weekend!");
});
# CHAPTER 3

OO injection object

import moment from "moment";
const RealTimeProvider = () =>  {
    this.getDay = () => moment().day()
};
const SUNDAY = 0, MONDAY=1, SATURDAY = 6;
class PasswordVerifier {
    constructor(rules, timeProvider) {
        this.rules = rules;
        this.timeProvider = timeProvider;
    }

    verify(input) {
        if ([SATURDAY, SUNDAY].includes(this.timeProvider.getDay())) {
            throw new Error("It's the weekend!");
        }
        const errors = [];
        //more code goes here..
        return errors;
    };
}
# CHAPTER 3
function FakeTimeProvider(fakeDay) {
    this.getDay = function () {
        return fakeDay;
    }
}
describe('verifier', () => {
    test('class constructor: on weekends, throws exception', () => {
        const verifier = 
            new PasswordVerifier([], new FakeTimeProvider(SUNDAY));

        expect(() => verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });
});

OO injection object

# CHAPTER 3

OO Extract interface

export interface TimeProviderInterface {  getDay(): number;  }



import * as moment from "moment";
import {TimeProviderInterface} from "./time-provider-interface";
export class RealTimeProvider implements TimeProviderInterface {
    getDay(): number {
        return moment().day();
    }
}
export class PasswordVerifier {
    private _timeProvider: TimeProviderInterface;
    constructor(rules: any[], timeProvider: TimeProviderInterface) {
        this._timeProvider = timeProvider;
    }
    verify(input: string):string[] {
        const isWeekened = [SUNDAY, SATURDAY]
            .filter(x => x === this._timeProvider.getDay())
            .length > 0;
        if (isWeekened) {throw new Error("It's the weekend!")}
         // more logic goes here
        return [];
    }}
# CHAPTER 3

OO Extract interface

class FakeTimeProvider implements TimeProviderInterface{
    fakeDay: number;
    getDay(): number {
        return this.fakeDay;
    }
}

describe('password verifier with interfaces', () => {
    test('on weekends, throws exceptions', () => {
        const stubTimeProvider = new FakeTimeProvider();
        stubTimeProvider.fakeDay = SUNDAY;
        const verifier = new PasswordVerifier([], stubTimeProvider);

        expect(() => verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });
});
# CHAPTER 4

Take away

Take away

Two types to break the dependencies: mocks and stubs
Mocks break outgoing dependencies
Stubs break incoming dependencies
Avoid using modular injection
Object as parameter (aka duck typing) like Golang style
Common interface as parameter - same as object as parameter

The Art of Unit Testing - Reading notes

By 范恆嘉(Benjamin Rice)