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));
}
});
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');
}
});
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)
});
});
}
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));
}
}));
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');
}
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.
- Role tests for implementation of interfaces discovered through TDD, Manuel Rivero
- Example of role tests in Java with Junit , Manuel Rivero
- Liskov Substitution Principle
Photo from Elina Sazonova in Pexels
Top comments (0)