Example of role tests in JavaScript with Jest

trikitrok

Manuel Rivero

Posted on January 23, 2023

Example of role tests in JavaScript with Jest

In this post we’ll show our last example applying the concept of role tests, this time in JavaScript using Jest. Have a look at our previous posts on this topic.

This example comes from a deliberate practice session we did recently with some developers from Audiense with whom we’re doing Codesai’s Practice Program in JavaScript twice a month.

Similar to what we did in our previous example of role tests in Java, we wrote the following tests to develop two different implementations of the TransactionsRepository port while solving the Bank Kata: the InMemoryTransactionsRepository and the NodePersistTransactionRepository.

These are their tests, respectively:

import {InMemoryTransactionRepository} from "../../src/inMemoryTransactionRepository";
import {aDeposit, aWithdrawal, date} from "../helpers";

describe('inMemory transaction repository', () => {
  let repository;

  beforeEach(() => {
    repository = new InMemoryTransactionRepository();
  });

  test('should save a transaction', () => {
    const transaction = aDeposit(100, date(2022, 6, 8));

    repository.save(transaction);

    expect(repository.getAll()).toStrictEqual([transaction]);
  });

  test('should add a transaction', () => {
    const transaction = aDeposit(100, date(2022, 6, 8));
    writeTransactions([transaction]);
    const newTransaction = aDeposit(200, date(2022, 6, 9));

    repository.save(newTransaction);

    expect(repository.getAll()).toStrictEqual([transaction, newTransaction]);
  });

  test('should get empty list of transactions if none saved', () => {
    const retrievedTransactions = repository.getAll();

    expect(retrievedTransactions).toStrictEqual([])
  });

  test('should get all transactions', () => {
    const transactions = [
      aDeposit(100, date(2022, 6, 8)),
      aWithdrawal(200, date(2022, 6, 9))
    ];
    writeTransactions(transactions);

    const retrievedTransactions = repository.getAll();

    expect(retrievedTransactions).toStrictEqual(transactions)
  });

  function writeTransactions(transactions) {
    transactions.forEach(t => repository.save(t));
  }
});
Enter fullscreen mode Exit fullscreen mode
import crypto from "crypto";
import fs from 'fs';
import {aDeposit, aWithdrawal, date} from "../helpers";
import {NodePersistTransactionRepository} from "../../src/nodePersistTransactionRepository";

describe('node-persist transaction repository', () => {
  let repository;
  const filePath = `./tmp/${md5('transactions')}`;

  beforeEach(() => {
    fs.rmSync('./tmp', {force: true, recursive: true});
    fs.mkdirSync('./tmp');
    repository = new NodePersistTransactionRepository('./tmp');
  });

  afterEach(() => {
    fs.rmSync('./tmp', {force: true, recursive: true});
  });

  test('should save a transaction', () => {
    const transaction = aDeposit(100, date(2022, 6, 8));

    repository.save(transaction);

    expect(readTransactions()).toStrictEqual([transaction]);
  });

  test('should add a transaction', () => {
    const transaction = aDeposit(100, date(2022, 6, 8));
    writeTransactions([transaction]);
    const newTransaction = aDeposit(200, date(2022, 6, 9));

    repository.save(newTransaction);

    expect(readTransactions()).toStrictEqual([transaction, newTransaction]);
  });

  test('should get empty list of transactions if none saved', () => {
    const retrievedTransactions = repository.getAll();

    expect(retrievedTransactions).toStrictEqual([])
  });

  test('should get all transactions', () => {
    const transactions = [
      aDeposit(100, date(2022, 6, 8)),
      aWithdrawal(200, date(2022, 6, 9))
    ];
    writeTransactions(transactions);

    const retrievedTransactions = repository.getAll();

    expect(retrievedTransactions).toStrictEqual(transactions)
  });

  function readTransactions() {
    let content = fs.readFileSync(filePath, {encoding: 'utf-8'});
    return JSON.parse(content).value.map(t => ({...t, date: new Date(t.date)}));
  }

  function writeTransactions(transactions) {
    fs.writeFileSync(filePath, JSON.stringify({
      key: 'transactions',
      value: transactions
    }), {encoding: 'utf-8'});
  }

  function md5(key) {
    return crypto.createHash('md5').update(key).digest('hex');
  }
});
Enter fullscreen mode Exit fullscreen mode

As what happened in our previous post, both tests contain the same test cases since both tests document and protect the contract of the same role, TransactionsRepository, which InMemoryTransactionsRepository and NodePersistTransactionRepository implement.

Again we’ll use the concept of role tests to remove that duplication, and make the contract of the role we are implementing more explicit.

Although Jest does not have something equivalent or similar to the RSpec’s shared examples functionality we used in our previous example in Ruby, we can get a very similar result by composing functions.

First, we wrote the behavesLikeATransactionRepository function. This function contains all the test cases that document the role and protect its contract, and receives as a parameter a testContext object containing methods for all the operations that will vary in the different implementations of this integration test.

import {aDeposit, aWithdrawal, date} from "../helpers";

export function behavesLikeATransactionRepository(testContext) {
  return () => describe('behaves like a transaction repository', () => {
    let repository;
    beforeEach(() => {
      testContext.init();
      repository = testContext.getInstance();
    });

    afterEach(() => {
      testContext.clean();
    });

    test('should save a transaction', () => {
      const transaction = aDeposit(100, date(2022, 6, 8));

      repository.save(transaction);

      expect(testContext.readTransactions()).toStrictEqual([transaction]);
    });

    test('should add a transaction', () => {
      const transaction = aDeposit(100, date(2022, 6, 8));
      testContext.writeTransactions([transaction]);
      const newTransaction = aDeposit(200, date(2022, 6, 9));

      repository.save(newTransaction);

      expect(testContext.readTransactions()).toStrictEqual([transaction, newTransaction]);
    });

    test('should get empty list of transactions if none saved', () => {
      const retrievedTransactions = repository.getAll();

      expect(retrievedTransactions).toStrictEqual([])
    });

    test('should get all transactions', () => {
      const transactions = [
        aDeposit(100, date(2022, 6, 8)),
        aWithdrawal(200, date(2022, 6, 9))
      ];
      testContext.writeTransactions(transactions);

      const retrievedTransactions = repository.getAll();

      expect(retrievedTransactions).toStrictEqual(transactions)
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Notice that in the case of Jest we are using composition, whereas we used inheritance in the case of Junit.

Then, we called the behavesLikeATransactionRepository function from the previous tests and implemented a particular version of the methods of the testContext object for each test.

This is the new code of InMemoryTransactionsRepositoryTest:

import {behavesLikeATransactionRepository} from "./transactionRepository.role";
import {InMemoryTransactionRepository} from "../../src/inMemoryTransactionRepository";

let repository;

describe('inMemory transaction repository', 
         behavesLikeATransactionRepository({
  init() {
    repository = new InMemoryTransactionRepository();
  },
  clean() {
  },
  getInstance() {
    return repository;
  },
  readTransactions() {
    return repository.getAll();
  },
  writeTransactions(transactions) {
    transactions.forEach(t => repository.save(t));
  }
}));
Enter fullscreen mode Exit fullscreen mode

And this is the new code of NodePersistTransactionRepository after the refactoring:

import crypto from "crypto";
import fs from 'fs';
import {NodePersistTransactionRepository} from "../../src/nodePersistTransactionRepository";
import {behavesLikeATransactionRepository} from "./transactionRepository.role";

const filePath = `./tmp/${md5('transactions')}`;

describe('node-persist transaction repository', 
          behavesLikeATransactionRepository({
  init() {
    fs.rmSync('./tmp', {force: true, recursive: true});
    fs.mkdirSync('./tmp');
  },
  clean() {
    fs.rmSync('./tmp', {force: true, recursive: true});
  },
  getInstance() {
    return new NodePersistTransactionRepository('./tmp');
  },
  readTransactions() {
    let content = fs.readFileSync(filePath, {encoding: 'utf-8'});
    return JSON.parse(content).value.map(t => ({...t, date: new Date(t.date)}));
  },
  writeTransactions(transactions) {
    fs.writeFileSync(filePath, JSON.stringify({
      key: 'transactions',
      value: transactions
    }), {encoding: 'utf-8'});
  }
}));

function md5(key) {
  return crypto.createHash('md5').update(key).digest('hex');
}
Enter fullscreen mode Exit fullscreen mode

This new version of the tests not only reduces duplication, but also makes explicit and protects the behaviour of the TransactionsRepository role. It also makes less error prone the process of adding a new implementation of TransactionsRepository because just by using the behavesLikeATransactionRepository function, you’d get a checklist of the behaviour you need to implement in order to ensure substitutability, i.e., to ensure the Liskov Substitution Principle is not violated.

These role tests using composition are also more readable than the Junit ones, in my opinion at least :)

Acknowledgements.

I’d like to thank Audiense’s deliberate practice group for working with us on this kata, and my colleague Rubén Díaz for co-facilitating the practice sessions with me.

Thanks to my Codesai colleagues for reading the initial drafts and giving me feedback, and to Elina Sazonova for the picture.

References.

Photo from Elina Sazonova in Pexels

💖 💪 🙅 🚩
trikitrok
Manuel Rivero

Posted on January 23, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related