This repository is for a workshop on testing. It is intended to be completed via Gitpod. Click the Gitpod button above to get started.
Add the following API endpoint:
GET /order-stats
Example payload:
{
"sunday": 1056,
"monday": 1511,
"tuesday": 1223,
"wednesday": 1342,
"thursday": 1497,
"friday": 2711,
"saturday": 1645
}
Create an empty test. We'll be using the mocha framework. It is already installed.
start/api/src/order-stats.a-test.ts
it("works", () => {});
Now we need a way to run our acceptance tests. Add the following to the scripts section of api/package.json
.
"test:acceptance": "mocha --require ts-node/register src/**/*.a-test.ts",
And now let's run the tests: npm run test:acceptance
They should be passing.
Replace your dummy test with the following
src/order-stats.a-test.ts
import { expect } from "chai";
import fetch from "node-fetch";
import config from "./config";
describe("GET /order-stats", () => {
it("should return a JSON object with days for keys and numbers for values", async () => {
const response = await fetch(`${config.test.baseUrl}/order-stats`);
expect(response.status).to.equal(200);
const stats = await response.json();
expect(typeof stats.monday).to.equal("number");
});
});
Add to config.ts
:
test: {
baseUrl: e.BASE_URL || "http://localhost:3000",
},
Run acceptance tests again: npm run test:acceptance
It should fail because you're getting a 404 but you expect a 200.
Opinion: why I suggest verifying little at this layer:
- Because I’d like to be able to run acceptance tests in a fully integrated environment where it will be much harder to control data
- Because I can verify exact values and shape with unit tests
Edit server.ts
and add this endpoint.
app.get("/order-stats", async (req: Request, res: Response) => {
const stats = {
monday: 1,
};
res.send(stats);
});
Run acceptance tests again: npm run test:acceptance
They should now be passing.
Opinion: Why implement this fake implementation?
- Prove that your tests work
- One failing test at a time
I've already provided order-repository.ts
so you can follow an established pattern for fetching data from the database. We'll make a new function in
this file to get the stats we need from the database. But first let's start
with a test!
Let's walk the pyramid from lightest to heaviest.
- Unit tests (insufficient)
- Integration tests (perfect)
- Acceptance tests (too heavy)
Create order-repository.i-test.ts
with our standard dummy test.
it("works", () => {});
Add to start/api/package.json
:
"test:integration": "mocha --require ts-node/register src/**/*.i-test.ts"
Run npm run test:integration
.
Expected result: passing
Opinion: Why have separate entrypoints for different types of tests?
- I tend to run tests at the bottom of the pyramid more frequently. For example, I often run my unit tests in watch mode all the time that I am coding, but I may run acceptance tests only when I finish a chunk of new code.
- You will likely want to run different test types during different phases of your build pipeline. You'll likely run unit tests as early as possible, but it is common to build and deploy your app somewhere and then run your acceptance tests against the environment you just deployed to, which happens much later in your build pipeline.
Add the following to models.ts
.
export interface AverageOrderSizeByDayOfWeekStatsRecord {
averageOrderAmount: number;
dayOfWeek: number;
}
This is what the raw data coming from the database will look like.
Replace our dummy integration test with a real one.
order-repository.i-test.ts
import { expect } from "chai";
import { getAvgOrderAmountByDay } from "./order-repository";
import { AverageOrderSizeByDayOfWeekStatsRecord } from "./models";
describe("order-repository", () => {
describe("#getAvgOrderAmountByDay", () => {
it("should return the avg amount per weekly day", async () => {
const expectedRecords: AverageOrderSizeByDayOfWeekStatsRecord[] = [
{ dayOfWeek: 0, averageOrderAmount: 1625 },
{ dayOfWeek: 4, averageOrderAmount: 5489 },
{ dayOfWeek: 5, averageOrderAmount: 941 },
];
const actualRecords = await getAvgOrderAmountByDay();
expect(actualRecords).to.eql(expectedRecords);
});
});
});
Add to order-repository.ts
.
export async function getAvgOrderAmountByDay(): Promise<
AverageOrderSizeByDayOfWeekStatsRecord[]
> {
const results = await pool.query(`
SELECT
CAST(ROUND(AVG(amount_cents)) AS INT) AS "averageOrderAmount",
DATE_PART('dow', created_at) AS "dayOfWeek"
FROM "order"
GROUP BY DATE_PART('dow', created_at)
ORDER BY "dayOfWeek" ASC
`);
return results.rows;
}
You'll need to import AverageOrderSizeByDayOfWeekStatsRecord
from models.ts
.
Run: npm run test:integration
Expected result: passing
Two things feel weird.
- The tests "hang" for a while at the end before the process exits.
- Where did that data come from? Can I trust that it?
We need to close the connection to postgres at the end of the test suite.
Create integration-test-setup.ts
with this content.
import { pool } from "./database-service";
after(() => {
pool.end();
});
Configure mocha to load this file prior first.
Edit the integration test script in package.json
to the following:
"test:integration": "mocha --require ts-node/register --file src/integration-test-setup.ts src/**/*.i-test.ts"
Run: npm run test:integration
Expected result: passes & exits immediately
When we ran our query to get average order amounts, we got some non-zero numbers, indicating that there are already orders in the database. How did they get there?
The data came from initdb/02-seed-data.sql
. This file provides enough seed
data for the app to run locally and look normal.
Should we rely on this data in our test suite? Probably not and here's why.
- If you want to add data for other use cases in the future, you're going to have have to edit any affected tests. This is an undesirable coupling that will cause you extra work.
- Data could be inserted into this database from other sources such as a human POSTing to
the
/orders
endpoint to do some manual testing or perhaps another integration test that inserts some data.
We should set up our tests to be able to fully control the data instead of relying on data expected to already be there.
To accomplish this, let's actually use a completely separate database that has no seed data in it! Then, for each test, we will insert data at the beginning of the test and delete it at the end of the test.
I have already set up a test database for you. Let's explore it for a moment. Go to your terminal tab labeled "DB".
select * from "order";
Here you can see our seed data. Now let's switch to our testing database.
\c order_management_test;
select * from "order";
Let's configure
our app to use the test database when running integration tests. If you look at config.ts
,
you'll see that we can override the postgres database by setting the POSTGRES_DATABASE
environment
variable.
Edit api/package.json
once again to override the database.
"test:integration": "POSTGRES_DATABASE=order_management_test mocha --require ts-node/register --file src/integration-test-setup.ts src/**/*.i-test.ts"
Run: npm run test:integration
Expected result: fail (no data)
Insert the data we need and clean it up when we are done by adding these blocks.
In order-repository.i-test.ts
, put these inside the describe("#getAvgOrderAmountByDay"
block.
before(async () => {
await pool.query(`
INSERT INTO "order"
(id, amount_cents, created_at, risk_score)
VALUES
('id-1', 1256, '2020-04-10', 22),
('id-2', 5489, '2020-04-02', 12),
('id-3', 625, '2020-04-03', 55),
('id-3', 1625, '2020-04-05', 66)
`);
});
after(async () => {
await pool.query('DELETE FROM "order"');
});
And import pool
.
import { pool } from "./database-service";
Run: npm run test:integration
Expected result: passing
The data is coming out of the database as a list of
AverageOrderSizeByDayOfWeekStatsRecord
s. But remember from the requirements that
we need it to look like this:
{
"sunday": 1056,
"monday": 1511,
"tuesday": 1223,
"wednesday": 1342,
"thursday": 1497,
"friday": 2711,
"saturday": 1645
}
Let's create a model for that. Let's define that interface in models.ts
.
export interface AverageOrderSizeByDayOfWeekStatsModel {
sunday: number;
monday: number;
tuesday: number;
wednesday: number;
thursday: number;
friday: number;
saturday: number;
}
And now we need to transform a list of AverageOrderSizeByDayOfWeekStatsRecord
s to a single
AverageOrderSizeByDayOfWeekStatsModel
. You might be tempted to do this in
order-repository.ts
, but don't!
- This is a separate concern (or responsibility) from interacting with the database. SRP (single responsibility principle) states that we shouldn't mix these two concerns in a single function.
- The test pyramid tells us to favor the bottom of the pyramid. This can easily be tested with a unit test. If we add this code to our repository layer, it must be tested with integration tests.
So let's implement this in the aptly named transforms.ts
file.
But, of course, let's start with a test first.
transforms.test.ts
import { expect } from "chai";
import { AverageOrderSizeByDayOfWeekStatsRecord } from "./models";
import { averageOrderSizeByDayOfWeekRecordsToModel } from "./transforms";
describe("transforms", () => {
describe("#averageOrderSizeByDayOfWeekRecordsToModel", () => {
context("when out of order and missing some data", () => {
it("should map all values correctly and default to 0", () => {});
const records: AverageOrderSizeByDayOfWeekStatsRecord[] = [
{ dayOfWeek: 0, averageOrderAmount: 10 },
{ dayOfWeek: 1, averageOrderAmount: 11 },
{ dayOfWeek: 3, averageOrderAmount: 13 },
{ dayOfWeek: 2, averageOrderAmount: 12 },
{ dayOfWeek: 4, averageOrderAmount: 14 },
{ dayOfWeek: 6, averageOrderAmount: 16 },
];
const expectedModel = {
sunday: 10,
monday: 11,
tuesday: 12,
wednesday: 13,
thursday: 14,
friday: 0,
saturday: 16,
};
const actualModel = averageOrderSizeByDayOfWeekRecordsToModel(records);
expect(actualModel).to.eql(expectedModel);
});
});
});
This is our first unit test. We need to set up a script in package.json
to run unit
tests.
"test:unit": "mocha --require ts-node/register src/**/*.test.ts"
Run: npm run test:unit
Expected result: fail (function doesn't exist)
Add to transforms.ts
:
function getAverageOrderAmountForDayOfWeekFromRows(
dayOfWeek: number,
rows: any[]
) {
const row = rows.find((x) => x.dayOfWeek === dayOfWeek);
return row ? (row.averageOrderAmount as number) : 0;
}
export function averageOrderSizeByDayOfWeekRecordsToModel(
records: AverageOrderSizeByDayOfWeekStatsRecord[]
): AverageOrderSizeByDayOfWeekStatsModel {
return {
sunday: getAverageOrderAmountForDayOfWeekFromRows(0, records),
monday: getAverageOrderAmountForDayOfWeekFromRows(1, records),
tuesday: getAverageOrderAmountForDayOfWeekFromRows(2, records),
wednesday: getAverageOrderAmountForDayOfWeekFromRows(3, records),
thursday: getAverageOrderAmountForDayOfWeekFromRows(4, records),
friday: getAverageOrderAmountForDayOfWeekFromRows(5, records),
saturday: getAverageOrderAmountForDayOfWeekFromRows(6, records),
};
}
You'll have to import AverageOrderSizeByDayOfWeekStatsRecord
and
AverageOrderSizeByDayOfWeekStatsModel
from models.ts
.
Run: npm run test:unit
Expected result: passing
We could go back to server.ts
and use our new functions directly there. I recommend
keeping your top-level application logic separate from protocols like HTTP though.
To that end, let's create the following:
order-service.ts
import * as orderRepo from "./order-repository";
import { averageOrderSizeByDayOfWeekRecordsToModel } from "./transforms";
export async function getAvgOrderAmountByDay() {
const records = await orderRepo.getAvgOrderAmountByDay();
const model = averageOrderSizeByDayOfWeekRecordsToModel(records);
return model;
}
We left our acceptance tests passing. Let's double check that they still are.
Run: npm run test:acceptance
Expected result: passing
Refactor the /order-stats
route in server.ts
to use the functions we just created.
app.get("/order-stats", async (req: Request, res: Response) => {
const stats = await getAvgOrderAmountByDay();
res.send(stats);
});
Import getAvgOrderAmountByDay
:
import { getAvgOrderAmountByDay } from "./order-service";
Run: npm run test:acceptance
Expected result: passing
- We didn't write any tests for
order-service.ts
. Should we have? - We didn't handle any error cases. What important error cases do you think we missed?
- Take a look at a more simple repository function like
getOrderById
. Does it warrant an integration test? And how do you feel about thatSELECT *
? Should we be specifying columns? - Take a look back at our overall testing values/goals and the testing pyramid. How well do you think we achieved those goals? Would you have approached this differently?
There are many types of tests. And their defintions are not always consistent. Here is a reference I think is brief and accurate: Types of tests
- Provide confidence of correctness
- Encourage pleasant coding experience
- Provide documentation
- Easy to write
- Easy to read/maintain
- Fast to execute
- Support refactoring (refactor production code without editing tests)
- Consistently passes or fails given unchanging code
What would you add to or remove from this list?