Skip to content

Latest commit

Β 

History

History
1015 lines (740 loc) Β· 27.1 KB

test-driven-dev.md

File metadata and controls

1015 lines (740 loc) Β· 27.1 KB

Test Driven Development

Test Driven Development (TDD) is a big deal in modern development.

  • Start by writing automated tests
  • BEFORE writing code that is being tested

Test-Running Systems in JavaScript:

  • Mocha
  • Jasmine
  • Tape
  • Jest

Syntax for all test running systems is very similar.

Benefits:

  • Produces clean code that works (goal of TDD)
  • Forces us to think through requirements/design before writing functional code
  • Keeps you out of the debugger
  • Reduces bugs in new/existing features
  • Reduces cost of change
  • Improves design
  • Encourages refactoring
  • Builds a safety net to defend against other programmers
  • Is Fun
  • Speeds up development by eliminating waste
  • Reduces fear
  • Improves productivity
  • Helps devs maintain focus
  • Improves communication
  • Communicate design decisions
  • Loosely-coupled design

WHY?

Jest

installation

npm i -D jest
// package.json
"scripts": {
  "test": "jest",
  "watch": "jest --watch *.js",
}

tdd syntax with Jest

// somefile.test.js
it("description", () => {
  expect(1).toBe(1); // pass
});

it("ordertotal, single item", () => {
  expect(
    orderTotal({
      items: [{ name: "dragon candy", price: 2, quantity: 3 }],
    }).toBe(6)
  );
});

running jest

npm run test # single test
npm run watch # continuous feedback loop

Common Matchers

.toBe() uses Object.is to test exact equality.

To check the value of an object, use .toEqual() or toStrictEqual() instead.

  • toStrictEqual() is preferred
  • toEqual() ignores undefined
test("object assignment", () => {
  const data = { one: 1 };
  data["two"] = 2;
  expect(data).toEqual({ one: 1, two: 2 });
});

To test the inverse of something, add .not to the matcher:

test("add positive numbers is not zero", () => {
  for (let a = 1; a < 10; a++) {
    for (let b = 1; b < 10; b++) {
      expect(a + b).not.toBe(0);
    }
  }
});

Truthiness Matchers:

  • toBeNull
  • toBeUndefined
  • toBeDefined
  • toBeTruthy
  • toBeFalsy

Numbers:

  • toBeGreaterThan
  • toBeGreaterThanOrEqual
  • toBeLessThan
  • toBeLessThanOrEqual
  • toBeCloseTo * floating point equality
    • ie: 0.1 + 0.2 .toBeCloseTo(0.3)-- NOT .toBe()

Strings Using Regex

  • toMatch(/stop/)
test("there is no I in team", () => {
  expect("team").not.toMatch(/I/);
});

test('but there is a "stop" in Christoph', () => {
  expect("Christoph").toMatch(/stop/);
});

Arrays & Iterables

  • toContain
const shoppingList = [
  "diapers",
  "kleenex",
  "trash bags",
  "paper towels",
  "milk",
];

test("the shopping list has milk on it", () => {
  expect(shoppingList).toContain("milk");
  expect(new Set(shoppingList)).toContain("milk");
});

Exceptions Error Throwing

To test if a function will throw an error: .toThrow

expect(() => compileAndroidCode()).toThrow();
expect(() => compileAndroidCode()).toThrow(Error);

Asynchronous Code Tests

Promises:

test("the data is peanut butter", () => {
  return fetchData().then((data) => {
    expect(data).toBe("peanut butter");
  });
});

Async/Await:

test("the data is peanut butter", async () => {
  const data = await fetchData();
  expect(data).toBe("peanut butter");
});

test("fetch fails w/ error", async () => {
  expect.assertions(1);
  try {
    await fetchData();
  } catch (e) {
    expect(e).toMatch("error");
  }
});

Async/Await with .resolves or .rejects

test("data is peanut butter", async () => {
  await expect(fetchData()).resolves.toBe("peanut butter");
});

test("fetch fails w/ error", async () => {
  await expect(fetchData()).rejects.toMatch("error");
});

Repeating Setup

If work needs to be repeated for many tests, you can use beforeEach and afterEach hooks.

Example: several tests that interact with a database

beforeEach(() => initializeCityDatabase());
afterEach(() => clearCityDatabase());

test("city database has Vienna", () => {
  expect(isCity("Vienna")).toBeTruthy();
});

beforeEach and afterEach can handle async code like tests:

  • take a done parameter
  • return a promise
// if initializeCityDatabase() returned a promise that resolved
// when the db was initialized, we want to return that promise:

beforeEach(() => {
  return initializeCityDatabase();
});

One-Time Setup

In some cases, you only need to setup once, at the beginning of a file. To prevent setups from running before each test, use beforeAll and afterAll hooks:

beforeAll(() => {
  return initializeCityDatabase();
});
afterAll(() => {
  return clearCityDatabase();
});
test("city database has Vienna", () => {
  expect(isCity("Vienna")).toBeTruthy();
});
test("city database has San Juan", () => {
  expect(isCity("San Juan")).toBeTruthy();
});

Scoping

By default, beforeAll and afterAll blocks apply to every test in a file.

To group tests together, use a describe block. When inside a describe block, beforeAll/afterAll blocks apply to tests within the describe block only.

describe("matching cities to foods", () => {
  // Applies only to tests in this describe block
  beforeEach(() => {
    return initializeFoodDatabase();
  });

  test("Vienna <3 veal", () => {
    expect(isValidCityFoodPair("Vienna", "Wiener Schnitzel")).toBe(true);
  });

  test("San Juan <3 plantains", () => {
    expect(isValidCityFoodPair("San Juan", "Mofongo")).toBe(true);
  });
});

Order of Execution

Jest executes

  1. ALL describe handlers in a file first
  2. Actual tests second

After describe blocks are complete, Jest runs all the tests in the order they were encountered in the collection phase. Each test waits on the previous test to complete before beginning.

beforeEach(() => console.log("connection setup"));
beforeEach(() => console.log("database setup"));

afterEach(() => console.log("database teardown"));
afterEach(() => console.log("connection teardown"));

test("test 1", () => console.log("test 1"));

describe("extra", () => {
  beforeEach(() => console.log("extra database setup"));
  afterEach(() => console.log("extra database teardown"));

  test("test 2", () => console.log("test 2"));
});

// connection setup
// database setup
// test 1
// database teardown
// connection teardown

// connection setup
// database setup
// extra database setup
// test 2
// extra database teardown
// database teardown
// connection teardown

General Advice

If a test is failing, one of the first things to check should be:

  • If that test fails when it's the only test that runs.
  • To complete a single test within a test file containing many tests, change test to test.only
// test.only
test.only("this will be the only test that runs", () => {
  expect(true).toBe(false);
});

// ignored with test.only present
test("this test will not run", () => {
  expect("A").toBe("A");
});

If a test passes when it's alone but fails as part of a larger suite, it's a good bet that something from a different test is interfering.

  • Can often fix this by clearing a shared state with beforeEach
  • Can also use a beforeEach statement to log data

Jest Mock Functions

Mock functions allow you to:

  • test links between code by erasing the actual implementation of a function
  • capturing calls to the function & the parameters
  • capturing instances of constructor functions when instantiated with new
  • test-time configuration of return values

Two ways to mock functions:

  • Creating mock function to use in test code
  • Writing manual mock to override a module dependency

Example: testing a function forEach > invokes a callback on each item in an array

function forEach(items, callback) {
  for (let i = 0; i < items.length; i++) {
    callback(items[i]);
  }
}

To test this function, we can use a mock function & inspect the mock's state to ensure the callback is invoked as expected:

const mockCallback = jest.fn((x) => 42 + x);
forEach([0, 1], mockCallback);

expect(mockCallback.mock.calls.length).toBe(2);
expect(mockCallback.mock.calls[0][0]).toBe(0);
expect(mockCallback.mock.calls[1][0]).toBe(1);
expect(mockCallback.mock.results[0].value).toBe(42);

Jest .mock property

All mock functions have a special .mock property. It contains

  • data about how the function has been called
  • what the function returned
  • tracks value of this for each call
const myMock1 = jest.fn();
const a = new myMock1();
console.log(myMock1.mock.instances);
// > [ <a> ]

const myMock2 = jest.fn();
const b = {};
const bound = myMock2.bind(b);
console.log(myMock2.mock.contexts);
// > [ <b> ]

// The function was called exactly once
expect(someMockFunction.mock.calls.length).toBe(1);

// The first arg of the first call to the function was 'first arg'
expect(someMockFunction.mock.calls[0][0]).toBe("first arg");

// The second arg of the first call to the function was 'second arg'
expect(someMockFunction.mock.calls[0][1]).toBe("second arg");

// The return value of the first call to the function was 'return value'
expect(someMockFunction.mock.results[0].value).toBe("return value");

// The function was called with a certain `this` context: the `element` object.
expect(someMockFunction.mock.contexts[0]).toBe(element);

// This function was instantiated exactly twice
expect(someMockFunction.mock.instances.length).toBe(2);

// The object returned by the first instantiation of this function
// had a `name` property whose value was set to 'test'
expect(someMockFunction.mock.instances[0].name).toBe("test");

// The first argument of the last call to the function was 'test'
expect(someMockFunction.mock.lastCall[0]).toBe("test");

Mock Return Values

const filterTestFn = jest.fn();

// Make the mock return `true` for the first call,
// and `false` for the second call
filterTestFn.mockReturnValueOnce(true).mockReturnValueOnce(false);

const result = [11, 12].filter((num) => filterTestFn(num));

console.log(result);
// > [11]
console.log(filterTestFn.mock.calls[0][0]); // 11
console.log(filterTestFn.mock.calls[1][0]); // 12

Mock Modules

users.js

import axios from "axios";

class Users {
  static all() {
    return axios.get("/users.json").then((resp) => resp.data);
  }
}

export default Users;

users.test.js

import axios from "axios";
import Users from "./users";

jest.mock("axios");

test("should fetch users", () => {
  const users = [{ name: "Bob" }];
  const resp = { data: users };
  axios.get.mockResolvedValue(resp);

  // or you could use the following depending on your use case:
  // axios.get.mockImplementation(() => Promise.resolve(resp))

  return Users.all().then((data) => expect(data).toEqual(users));
});

Mock Implementations

Define the default implementation of a mock function that is created from another module:

jest.mock("../foo"); // this happens automatically with automocking
const foo = require("../foo");

// foo is a mock function
foo.mockImplementation(() => 42);
foo();
// > 42

Recreate complex behavior of a mock function - multiple function calls that produce different results:

const myMockFn = jest
  .fn()
  .mockImplementationOnce((cb) => cb(null, true))
  .mockImplementationOnce((cb) => cb(null, false));

myMockFn((err, val) => console.log(val)); // > true
myMockFn((err, val) => console.log(val)); // > false

//
// default implementation (calls beyond first/second)
//

const myMockFn = jest
  .fn(() => "default")
  .mockImplementationOnce(() => "first call")
  .mockImplementationOnce(() => "second call");

console.log(myMockFn(), myMockFn(), myMockFn(), myMockFn());
// > 'first call', 'second call', 'default', 'default'

//
// methods that are chained (always need to return this)
//

const myObj = {
  myMethod: jest.fn().mockReturnThis(),
};

// is the same as

const otherObj = {
  myMethod: jest.fn(function () {
    return this;
  }),
};

//
// Mock Function Names, instead of displaying jest.fn()
//

const myMockFn = jest
  .fn()
  .mockReturnValue("default")
  .mockImplementation((scalar) => 42 + scalar)
  .mockName("add42");

Custom Matchers

// The mock function was called at least once
expect(mockFunc).toHaveBeenCalled();

// The mock function was called at least once with the specified args
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);

// The last call to the mock function was called with the specified args
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);

// All calls and the name of the mock is written as a snapshot
expect(mockFunc).toMatchSnapshot();

// sugar for common forms of inspecting .mock property
// The mock function was called at least once
expect(mockFunc.mock.calls.length).toBeGreaterThan(0);

// The mock function was called at least once with the specified args
expect(mockFunc.mock.calls).toContainEqual([arg1, arg2]);

// The last call to the mock function was called with the specified args
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1]).toEqual([
  arg1,
  arg2,
]);

// The first arg of the last call to the mock function was `42`
// (note that there is no sugar helper for this specific of an assertion)
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1][0]).toBe(42);

// A snapshot will check that a mock was invoked the same number of times,
// in the same order, with the same arguments. It will also assert on the name.
expect(mockFunc.mock.calls).toEqual([[arg1, arg2]]);
expect(mockFunc.getMockName()).toBe("a mock name");

Mock Tests

Running lots of tests to API's can become unfeasible: charging credit cards, etc.

Examples: VAT (value added tax) varies based on the country. VAT API.

  • Change thinking process
function orderTotal(fetch, order) {
  fetch();
  return Promise.resolve(
    order.items.reduce((prev, cur) => cur.price * (cur.quantity || 1) + prev, 0)
  );
}

module.exports = orderTotal;
//  sandbox.js
const fetch = require("node-fetch");

const result = fetch("vatapi.com/...")
  .then((response) => response.json())
  .then((data) => data.rates.standard.value);

Change tests to expect a promise

const orderTotal = require("./order-total");
const emptyFunction = () => {};

it("calls vatapi.com if country code specified", () => {
  let isFakeFetchCalled = false;
  const fakeFetch = (url) => {
    isFakeFetchCalled = true;
  };
  orderTotal(fakeFetch, {
    country: "DE",
    items: [{ name: "dragon waffles", price: 20, quantity: 2 }],
  }).then((result) => expect(isFakeFetchCalled).toBe(true));
});

it("no qty specified", () =>
  orderTotal(emptyFunction, {
    items: [{ name: "dragon candy", price: 3 }],
  }).then((result) => expect(result).toBe(3)));

More Testing

Another important basic concept is testing in isolation. Only one method should be tested at a time and your tests should not depend on an external function behaving correctly --- especially if that function's being tested elsewhere.

Main reason for testing in isolation:

  • When tests fail, you want to be able to narrow down the cause of this failure as quickly as possible
  • If a test depends on several functions, it can be difficult to tell what exactly is going wrong

Pure Functions

Tightly coupled code is hard to test.

For example:

function guessingGame() {
  const magicNumber = 22;
  const guess = prompt("guess a number between 1 and 100!");
  if (guess > magicNumber) {
    alert("YOUR GUESS IS TOO BIG");
  } else if (guess < magicNumber) {
    alert("YOUR GUESS IS TOO SMALL");
  } else if (guess == magicNumber) {
    alert("YOU DID IT! πŸŽ‰");
  }
}

Making this testable requires splitting up all the different things that are happening. What we need to test is the number logic, which is much easier to untangle it:

function evaluateGuess(magicNumber, guess) {
  // * Only function that needs to be tested*
  if (guess > magicNumber) {
    return "YOUR GUESS IS TOO BIG";
  } else if (guess < magicNumber) {
    return "YOUR GUESS IS TOO SMALL";
  } else if (guess == magicNumber) {
    return "YOU DID IT! πŸŽ‰";
  }
}

function guessingGame() {
  const magicNumber = 22;
  const guess = prompt("guess a number between 1 and 100!");
  const message = evaluateGuess(magicNumber, guess);
  alert(message);
}

guessingGame();

This implementation is much nicer:

  • Clear input
  • Clear output
  • Doesn't call any external functions
  • Easier to extend

If this was written with TDD, it would have looked more like the 2nd example.

TDD encourages better program architecture because it encourges us to write PURE FUNCTIONS.

Mocking

Tightly coupled code has two solutions:

  1. Best*: Remove those dependencies, as we did in example 2.
  2. Mocking: writing fake versions of a function that always behaves exactly how you want.

Mocking example: testing a function that gets info from DOM input. You don't want to have to setup a webpage and dynamically insert something into the input just to run your tests.

With a mock function, we can create a fake version of the input-grabbing that always returns a specific value --- and use THAT in your test.

Too much mocking can be a bad thing. It is sometimes necessary, but if you have to setup an elaborate system of mocks to test any bit of your code, that means your code is too tightly coupled.

One thing you should NEVER do: Share state between tests.

Testing is NOT what you should spend most of your time doing.

Test assertions (.equal, .deepEqual) should be dead simple and completely free of magic. WHY?

  • Provide quality info about expectations
  • Lead to concise test cases
  • Easy to read & maintain

Test cases should be written just like a bug report:

  1. Describe the feature you're testing in plain English.
  2. Provide expected outcome.
  3. Compare expectation to the actual value.

When a unit test fails, the error message is your bug report.

  • Your automated test error messages are your bug reports.

Your test descriptions should be clear enough to use as documentation:

import test from "tape";

test("A passing test", (assert) => {
  assert.pass("This test will pass.");
  assert.end();
});

test("Assertions with tape.", (assert) => {
  const expected = "something to test";
  const actual = "sonething to test";

  assert.equal(
    actual,
    expected,
    "Given two mismatched values, .equal() should produce a nice bug report"
  );

  assert.end();
});

Mocking becomes a requirement when there is coupling between units and unit isolation is needed for testing.

Tight Coupling: Rigid, brittle code - more likely to break when changes are needed

Less Coupling: Easy to extend & maintain. Testing becomes easier, which is a bonus side effect.

If we're mocking something, there may be a way to reduce coupling and make our code more flexible.

Coupling: Degree to which a module, function, class, etc. depends on other units of code. Coupling comes in many forms:

  • Subclass Coupling: inheritance
  • Control Dependencies: code that controls its dependencies by telling them what to do.
    • Passing method names
    • If control API of dependency changes, dependent code breaks.
  • Mutable State Dependencies: code that shares a mutable state w/ other code
    • Can change properties on a shared object
    • If relative timing of mutations change, dependent code can break
  • State Shape Dependencies: code that shres data structures with other code & only uses a subset of the structure
  • Event/message coupling: code that communicates w/ units via message passing, events, etc.

Tight Coupling:

  • Mutation vs immutability
  • Side-Effects vs purity/isolated side-effects
  • Responsibility Overload vs Do One Thing (DOT)
  • Procedural Instructions vs describing structure
  • Class Inheritance vs composition

Imperative/Object-oriented code is more susceptible to tight coupling.

Functional code is less susceptible. Functional code uses pure functions as the core unit of composition and are less vulnerable to tight coupling by nature.

Pure Functions:

  • Given the same input, always return the same output
  • Produce no side-effects
  • Immutability: Don't mutate existing values. They return new ones instead.
  • No Side Effects: Only thing pure functions do is return a value. It doesn't interfere with the operation of other functions.
  • Do One Thing (DOT): Avoid responsibility overload, which plague object & class-based code.
  • Structure, not instructions: Pure functions can be replaced with a lookup table (input/output values).
    • Pure functions describe the structural relationship between data - NOT instructions for the computer to follow

Composition & Mocking

Software development: process of breaking large problem into smaller, independent pieces (decomposition) and composing the solutions to form an app that solves the large problem (composition).

Mocking is required WHEN our decomposition strategy has failed.

When decomposition succeeds, we can use a generic composition utility to compose the pieces back together:

  • Function composition: lodash/fp/compose
  • Component Composition: composing higher-order components w/ function composition
  • State store/model composition: Redux combineReducers
  • Object or factory composition: mixins, functional mixins
  • Process composition: transducers
  • Promise/monadic composition: asyncPipe(), composeM()

Function composition = applying a function to the return value of another function.

  • Create pipeline of functions
  • Pass value to pipeline
  • Value goes thru each function (stage in an assembly line)
  • Transforms value in some way before it's passed to the next function in the pipeline
  • Last function returns final value
const pipe =
  (...fns) =>
  (x) =>
    fns.reduce((y, f) => f(y), x);
// Applying a function to the return value of another function
const pipe =
  (...fns) =>
  (val) =>
    fns.reduce((prevVal, fun) => fun(prevVal), val);

// EXAMPLE
// initialValue -> [plusOne] -> [timesTwo] -> result
const plusOne = (n) => n + 1;
const timesTwo = (n) => n * 2;

const declarativeComposition = pipe(plusOne, timesTwo);
console.log(declarativeComposition(20));

This is the primary means of organizing application code in every mainstream language, regardless of paradigm.

JavaScript has first-class functions, which allows you to compose functions automatically (declaratively).

  • Imperative: commanding the computer to do something step by step
  • Declarative: telling computer the relationships between things
    • A description of structure using equational reasoning

declarativeComposition() is the piped composition of plusOne and timesTwo.

How To Remove Coupling

Loose Coupling:

  • Module imports without side-effects
  • Message passing/pubsub
  • Immutable parameters

To remove coupling:

  • Use Pure Functions as the atomic unit of composition
    • Instead of classes, imperative procedures, mutating functions
  • Isolate Side-Effects from the rest of your logic.
    • Don't mix logic with I/O (network I/O, rendering UI, logging)
  • Remove Dependent Logic from imperative compositions

DON'T unit test I/O.

I/O is for integrations. Use integration tests, instead.

It's perfectly okay to mock and fake for integration tests.

Pure Functions

Pure functions

  • Can't mutate global variables
  • Can't mutate arguments passed into them
  • Can't mutate the network
  • Can't mutate the disk
  • Can't mutate the screen

Pure functions can ONLY return a value.

If passed an array/object, pure functions have to create a new copy of the array/object with the require changes.

  • Arrays methods: concat, filter, map,reduce,slice
  • Object methods: Object.assign
// NOT PURE * BAD
const signInUser = (user) => (user.isSignedIn = true);
const foo = {
  name: "Foo",
  isSignedIn: false,
};
// Foo was mutated * BAD
console.log(
  signInUser(foo), // true
  foo // { name: "Foo", isSignedIn: true }
);

// VERSUS
// PURE * GOOD -> returns a new object
const signInUser = (user) => ({ ...user, isSignedIn: true });
const foo = {
  name: "Foo",
  isSignedIn: false,
};
// Foo was not mutated
console.log(
  signInUser(foo), // { name: "Foo", isSignedIn: true }
  foo // { name: "Foo", isSignedIn: false } * NOT MUTATED
);

Performance hits due to returning a new object

  • Side-effect: we can detect changes to objects by using === identity comparison
  • Don't have to traverse through an entire object to discover changes

Pure functions can be memoized: cache/save pre-calculated values in a lookup table.

Pure functions allow you to safely distribute complex computations over large clusters of processors (divide & conquer).

Side Effects

Side Effects include, but are not limited to:

  • Making HTTP Request
  • Mutating data
  • Printing to a screen or console
  • DOM query/manipulation
  • Math.random()
  • Getting current time

WHAT to test in your code base

Incoming Messages > (Object Under Test > Messages Sent to Self) > Outgoing Messages

Query Message

  • Return something
  • Change nothing

Command:

  • Return nothing
  • Change something

Command & queries are often used together: queue.pop()

Message Query Command
Incoming Assert Results Assert direct public side effects
Sent to Self IGNORE IGNORE
Outgoing IGNORE Expect to send (Mocks / test double)

Incoming Query Messages

  • Test incoming query messages by making assertions about what they send back
    • Gear Inches > ratio * wheel.diameter
  • Test the interface NOT the implementation

Incoming Command Messages

  • Test incoming command messages by making assertions about direct public side effects
    • Create instance of an object
    • Create a side effect
    • Assert about the value of the side effect
    • gear = new Gear() > gear.set_cog(27) > assert(27, gear.cog)

DRY It Out

Receiver of incoming message has sole responsibility for asserting the result direct public side effects

Messages Sent To Self

  • DO NOT test private methods
  • Do NOT make assertions about their result
  • Do NOT expect to send them
  • Caveat: Break rule if it saves $$$ during development

Outgoing Query Message

  • Same rules as sent to self
  • gear_inches > GEAR > diameter (outgoing) > incoming query > WHEEL

If a message has no visible side-effects, the sender should NOT send it

Outgoing Command Message

  • Expect to send outgoing command messages

  • Break rule if side effects are stable and cheap

  • Mocks: Honor the contract

    • Ensure test doubles stay in sync with the API
    • Further away & more expensive

    gear_inches ----> GEAR set_cog --------> * outgoing, side effects

    diameter -------> WHEEL

    changed --------> OBS * side effects

Summary

  • Be a minimalist
  • Use good judgement
  • Test everything once
  • Test the interface
  • Trust collaborators
  • Insist on simplicity
  • Practice the tricks