A Sandwich

Routine Sub

Just a normal sandwich

Given 3

Last updated on 4/28/2024
Filed Under: Testing, BDD

Given 3 is a library for making it much cleaner to setup tests in Typescript and Javascript. It was strongly inspired by the library given2 but with with a few core concepts added:

  1. Type safety
  2. Ability to refine values
  3. Ability to re-use values between tests
  4. A way to specify cleaning up values

Before getting into how this makes testing easier I think it's worth touching on each of these points directly.

Type safety


// given2 has nice syntax but it's all just any typed
given2('counter', () => 2);

// given3 get's type-safety by creating a Given<T> object
const counter = given3(() => 2);

it('has the counter value', () => {
expect(given2.counter).toBe(2); // passes
expect(counter.value).toBe(2); // passes
})

it('has a typo' () => {
expect(given2.conter).toBe(2); // failed - undefined !== 2
expect(conter.value).toBe(2); // type error conter is undefined
})

Refining values


// given2 required you to create new givens to refine the value
given2('counter', () => 2);
given2('counterPlusOne', () => given.counter + 1);

// given3 allows given's to refer to their previous values
const counter = given3(() => 2);
counter.define(() => counter.value + 1);

it('has a counter value of 3', () => {
expect(given2.counter).toBe(3); // failed 2 !== 3
expect(counter.value).toBe(3); // passes
})

Reusing values between tests


describe('a test suite', () => {
// given2 lazily computes and caches the value, the cache is released
// in an "afterEach" hook.
let g2Counter = 0;
given2('counter', () => g2Counter++);

// given3 supports a scope argument for it's cache, moving the release
// to the "afterAll" hook.
let g3Counter = 0;
const counter = given3(() => g3Counter++, { scope: 'All' })


it('test 1', () => {
expect(given2.counter).toBe(1); // depends on order of execution.
expect(counter.value).toBe(1); // passes
})

it('test 2', () => {
expect(given2.counter).toBe(1); // depends on order of execution.
expect(counter.value).toBe(1); // passes
})

})

Cleaning up values


// given2 had no way to do cleanup
given2('tempFile', () => {
writeFileSync('myFile.json', JSON.stringify({ test: true }));
return 'myFile.json';
});

// given3 specifies a way to cleanup values
const tempFile = given3(() => {
writeFileSync('myFile.json', JSON.stringify({ test: true }));
return 'myFile.json'
}).cleanup((fileName) => {
rmSync(fileName);
})

Rethinking Arrange, Act, Assert

Arrange, Act, Assert is something that you learn about early on when talking about unit tests. Tests written with this framework start by arranging their system, setting up dependencies and generally getting things into the nessesary state for testing. Then they act on the system in a way that is relevent to the tests, and finally they assert the state of the system has changed in an expected way, or the expected result has been returned.

it('keeps a running sum of values', () => {
// arrange: we create the system we're testing
// and get it into a known state.
const calculator = new Calculator();
calculator.enter(1);

// act: we trigger the functionality we're testing
const result = calculator.sum(2);

// assert: we verify that the behavior is as we've intended.
expect(result).toBe(3);
})

We can take Arrange, Act, Assert and map it to given

describe('calculator tests', () => {
// arrange
const calculator = given(() => new Calculator());
calculator.define(() => {
calculator.value.enter(1);
return calculator.value;
})

// act
const sumResult = given(() => calculator.value.sum(2));

// assert
it('keeps a running sum of values', () => {
expect(sumResult.value).toBe(3);
})
})

Given helps to reduce the overhead of needing to write the repeated setup steps, in the example above we could make more assertions on the calculator without repeating the setup. However the lazily computed nature of given's means that we can do much more by re-arranging our test strcture and embracing the seeminly unnatural Act, Arrange, Assert structure.

describe('calculator tests', () => {
const calculator = given(() => new Calculator());
describe('sum with 2', () => {
// act
const sumResult = given(() => calculator.value.sum(2));

describe('given the inital value is 1', () => {
// arrange
calculator.define(() => calculator.vlaue.enter(1));

//assert
it('returns 3', () => {
expect(sumResult).toBe(3)
})
})

})
})

The fact that the same test can be written with the act and arrange patterns inverted to act, arrange, assert means that you can now logically structure your test cases with a hierarchy: What You're Test > Conditions In which the test is being run > Expected Results.