Better Unit Testing

Unit tests

Unit = Unit of work

This could involve multiple methods and classes invoked by some public API that can:

  • Return a value or throw an exception
  • Change the state of the system
  • Make 3rd party calls

Basic

  1. Test case
  2. Test suit
  3. Stub
  4. Mock
  5. Coverage

Test case

A test case is the smallest unit of testing. It checks for a specific response to a particular set of inputs.

You may do these things in a test case:

  1. Prepare/mock input data
  2. Apply action
  3. Check the response

Format

it('should [expected behaviour] when [scenario/context]', () => {
  // Your test logic
});

Example

it("should return 2 when 1+1", () => {
  expect(add(1, 1)).toBe(2);
});

Some times you need to split it into multiple lines

:(

it("should return all matched person", async () => {
  expect(await personService.getPersonsByIds([1, 2, 3])).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }
  ]);
});


:)

it("should return all matched person", async () => {
  const result = await personService.getPersonsByIds([1, 2, 3]);
  expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
});

But keep in mind that split code into multiple lines is the last choice, put them in same line when possible.

Test suit

A test suite is a collection of test cases, test suites, or both. It is used to aggregate tests that should be executed together.

Testing a function

Format

describe("[functionName()]", () => {
  it("should [expected behavior] when [scenario/context]");
});

Example

describe("add()", () => {
  it("should work with int");
  it("should return NaN when add a NaN");
  it("should concern about preciseness");
});

Testing a class

Format

describe('[ClassName]', () => {
  describe('[functionName()]', () => {
    it('should [expected behaviour] when [scenario/context]');
  }
});

Example:

describe("Calculator", () => {
  describe("add()", () => {
    it("should work with int");
    it("should return NaN when add a NaN");
    it("should concern about preciseness");
  });
});

Mocking

Mocking is one particlar technique to allow testing of a unit of code with out being reliant upon dependencies.

Mock could do these for you:

  • Erasing the actual implementation of a function
  • Capturing calls to the function
  • Capturing instances of constructor functions when instantiated with new
  • Allowing test-time configuration of return values.
  • Boost test speed.

Ensure function called

import EventEmitter2 from 'eventemitter2';

class UploadManager extends EventEmitter2 {
  on(type, listener) {
    super.on(type, listener);
  }
  emit(type, ...arg) {
    super.emit(type, ...arg);
  }
}
it('should call the event handler', () => {
  const handler = jest.fn();
  uploadManager.on('1', handler);
  uploadManager.emit('1');
  expect(handler).toHaveBeenCalled();
});

Configure return values

import PostAPI from 'sdk/api/glip/post';

export default class PostService extends BaseService {
  async getPostsFromRemote({ groupId, postId, limit, direction }) {
    // ...
    const requestResult = await PostAPI.requestPosts(params);
    // ...
    return result;
  }
}
describe('getPostsFromRemote()', () => {
  it('should return posts of the group from remote', async () => {
    PostAPI.requestPosts.mockResolveValue({ data: { posts: postsOfGroup3 } });
    const result = await postService.getPostsFromRemote({ groupId: 3 });
    expect(result.posts).toBe(postsOfGroup3);
  });
});

Replace dependecies module with mock

// utils.js
export const fn1 = () => {};
// doSomeThing.js
import { fn1 } from './utils';
export default function doSomeThing() {
  // ...
  fn1();
  // ...
}
// doSomeThing.test.js
import { fn1 } from './utils';
import doSomeThing from './doSomeThing';

jest.mock('./utils');

it('should call fn1', () => {
    doSomeThing();
    expect(fn1).toHaveBeenCalled();
});

Coverage

test coverage is a measure used to describe the degree to which the source code of a program is executed when a particular test suite runs.

Coverage report

image-20180424132752201

Coverage detail

image-20180425230333062

Best Practice

Unit tests are isolated and independent of each other

  • Any given behaviour should be specified in one and only one test
  • The execution/order of execution of one test cannot affect the others

:(

const store = new Store();
it('should append object', () => {
  store.append({id: 1});
  expect(store.getById(1)).toBe({id: 1});
});

it('should remove object', () => {
  store.remove(1);
  expect(store.getById(1)).toBeNull();
});

:)

it('should append object', () => {
  const store = new Store();
  store.append({id: 1});
  expect(store.getById(1)).toBe({id: 1});
});

it('should remove object', () => {
  const store = new Store([{id: 1}]);
  store.remove(1);
  expect(store.getById(1)).toBeNull();
});

Unit tests are code too.

  • They must meet the same level of quality as the code being tested.
  • They can be refactored as well to make them more maintainable and/or readable.
  • They should be review first when applying a code review.

Unit tests are lightweight tests

  • Repeatable
  • Fast
  • Consistent
  • Easy to write and read

Make your tests:

  • Readable
  • Maintainable
  • Reliable

:(

expect(result === obj).toBe(true)

image-20180423200008813


:)

expect(result).toBe(obj)

image-20180423200123033

:(

expect(result.length).toBe(2)

image-20180423200452028

:)

expect(result).toHaveLength(2)

image-20180423200545697

:(

const result = store.getById(1);
expect(result.id).toBe(1);
expect(result.name).toBe('Object 1');
expect(result.group).toBe(2);
expect(result.type).toBe(3);

image-20180423203750919

:)

const result = store.getById(1);
expect(result).toBe(obj1);

image-20180423204020777

:(

store.append({id: 1, name: 'Object 1', group: 2, type: 3});
store.append({id: 2, name: 'Object 2', group: 2, type: 3});

image-20180423204020777

:)

store.append({id: 1});
store.append({id: 2});

image-20180423210810014

:(

store.append(obj);
expect(store.items.find(o => o.id === 1)).toBe(obj);

:)

store.append(obj);
expect(store.getById(1)).toBe(obj);

:(

it('should return 100 if type is 1 and 200 if type is not 1', () => {
  const obj1 = {id: 1, type: 1};
  const obj2 = {id: 2, type: 2};
  const obj3 = {id: 3, type: 3};
  [obj1, obj2, obj3].forEach(o => {
    if(o.type === 1) {
      expect(getPrice(o)).toBe(100);
    } else {
      expect(getPrice(o)).toBe(210);
    }
  });
});

image-20180423212338400

:)

it('should return 100 when type is 1', () => {
  expect(getPrice({id: 1, type: 1})).toBe(100);
});

it('should return 200 when type is not 1', () => {
  expect(getPrice({id: 1, type: 2})).toBe(200);
});

image-20180423213042759

image-20180423213053144

Test Driven

Test first.

life-cycle

image-20180424105404677

Why use TDD?

  • Change the way of thinking.
  • Setup a tiny enviorment for development and debug.
  • Don't have to run entire application to verify code.

Always use TDD?

NO

Don't use TDD unless it makes you feel good and confident.

CI

image-20180425155122051

image-20180424142014816

Tools

Reference