Using layered context functions to share objects for Jest integration testing
Published:
Heads up! This content is more than six months old. Take some time to verify everything still works as expected.
A habit I developed a while back was learning to clean up after myself. Deal with a legacy database that you can't just blow away and you'll come to realize how important that is. We can, and should mock away some of those concerns when dealing with external systems. When unit testing, there is often nothing to clean up anyway, or it's simple.
The technique I'll show here today is not for those use cases. It's designed to help you with a full CRUD cycle through API graphql query and mutation, resolvers, utils, and eventual database. Real data gets created, updated, processed, and removed. Yes, I know that might violate some imagined laws of testing. Guess what, it works really, really well. When working with Proof of Concept ideas, a full suite of unit tests is overkill. Write one once a bug is identified, or the logic can't be factored under reasonable cyclomatic complexity limits. Unit tests work, but they say nothing about if the system is working on the whole.
Let's get to it.
Overview
Today's tests will be written in Jest. The
layering of context functions would apply as well in many other tools. Jest
will be able to run through npm test
, or your own built in IDE controls
with full debugger breakpoint control. Our API will be an
apollo-server-express
running GraphQL. We'll hand waive authorization, as that will be specific
to your implementation.
Today's task will be about manipulating an abstract Group
that can take
generic GroupMember
(not even users, just objects).
groups.graphql
For specific details of what we'll be testing, you're welcome to review the schema:
extend type Query { "Returns all groups available to this users" groups: [Group!]! group(id: ID!): Group! } extend type Mutation { saveGroup(group: GroupInput!): Group! deleteGroup(id: ID!): Group "Adds a member to a group, based on the target selection, and returns the membership and a structure edge if created" addMemberToGroup(memberId: ID!, groupId: ID!): [GroupEdge!]! "Removes a member from a group, based on the target selection, and returns edges removed" removeMemberFromGroup(memberId: ID!, groupId: ID!): [GroupEdge!]! } type Group { "Unique identifier for the resource across all collections" id: ID "A preformatted display name safe to display in HTML context" displayName: String! "ISO date time String for the time this resource was created" createdAt: String "Unique identifier for users that created this resource" createdBy: ID "ISO date time String for the time this resource was created" updatedAt: String "Unique identifier for users that created this resource" updatedBy: ID "Short description" description: String "The documents that are a part of the group" members: [GroupMember!] } input GroupInput { "Unique identifier for the resource within its collection" id: ID "A preformatted name safe to display in any HTML context" displayName: String! "Short description" description: String } "A common set of interfaces implemented by all members allowing generic handling" type GroupMember { "Unique identifier for the resource across all collections" id: ID "A preformatted display name safe to display in HTML context" displayName: String! "ISO date time String for the time this resource was created" createdAt: String "Unique identifier for users that created this resource" createdBy: ID "ISO date time String for the time this resource was created" updatedAt: String "Unique identifier for users that created this resource" updatedBy: ID }
Integration testing
For those not familiar with Jest, Mocha, Chai, Cypress, the syntax is simple enough:
// Create an arbitraty group of tests relating to some concept describe("groups", () => { // Logic you can run before our tests in this group beforeAll(() => {}); // Prepare a specific piece of fucntionality you want to know is working it("should create a group", async () => { // Implement creation and expectation logic }); // Logic you can run after our tests in this group beforeAll(() => {}); });
Context functions
I use context functions as a means of organizing code. You could do much
of this inline, using beforeAll
, but it gets complicated as tests
require additional specifics for operations. Using context functions
allow you separate test specific requirements from describe
group
concerns.
The goal will become to write tests that look like this. We eliminate
the beforeAll
setup requirements, and pull just the pieces we need
from context during testing.
describe("Groups", () => { it("should create", async (done) => { await withGroupContext(async ({ group }) => { expectGroup(group); // Specifics are not important for this example }); }); });
What exactly does withGroupContext
offer?
- A
let
variable we can use for storage to ensure we can cache results. - A
Group
created through a utility function that requiresResolverContext
.
// A helper context function to ensure all tests have access to the Group. // This function can be placed anywhere, such as the end of the relevant // `describe`, or at the bottom. // One important bit, do not export `withGroupContext`. It relies // on `let group`. It works well in one file, but `npm test` will suffer // polution. let group: Group | undefined; type TWithGroupContext = ( context: ResolverContext & { group: Group; } ) => Promise<void>; const withGroupContext = async (fn: TWithGroupContext) => { await withResolverContext(async (context) => { if (!group) { group = await resolverSaveGroup(context, { group: { description: "Generated through resolver tests", displayName: new Date().toLocaleString(), }, }); } await fn({ ...context, group }); }); };
And withResolverContext
, what is that?
import { Application } from "express"; export interface ResolverContext { // A set of system contexts, in case I need to alter the DB directly. context: SystemContext; // An initialized application we can hand off to supertest application: Application; // An auth token to include on requests authorization: string; } // Some local cache variables let application: Application; let context: SystemContext; // Definition of what we'll offer type TWithResolverContext = (context: ResolverContext) => Promise<void>; // Implementation export const withResolverContext = async ( fn: TWithResolverContext ): Promise<void> => { if (!application) { application = await getApp(); } if (!context) { context = await getSystemContext(); } // This will be up to your requirements const authorization = await getAuthorization(); await fn({ context, application, authorization, }); }; let app: Application; export const getApp = async (): Promise<Application> => { if (app) { return app; } // Whatever your application start up logic is, so long as it's async. // I started using async start up apps so database and message queue // schemas could run first. The pay off has been huge. app = await getApplication(); return app; };
These examples are super basic. A resolver context function that provides nothing more than the app, authorization, and some utilities. Layered on that is a single group we created. It demonstrates how I like to separate concerns and implementation into small reusable pieces. How that gets used is next.
Integration testing example
Here's the result of those context functions I've omitted past the basic concepts to focus on usage. The lovely parts about writing this way to me are:
- You can fire any single test, and it always has all the data required.
- In a debugger breakpoint, you can stop the test and data will be left in the system.
- If you allow it to finish, all data will be cleared, even if you don't run the
delete
test. Pay special attention to thedelete
andafterAll
handling at the end for how this works.
You'll notice a lot of types being imported from ../../generated/types
. We're wired up
to GraphQL Code Generator which produces
Typescript based on our earlier groups.graphql
file. For details see my
Apollo server Express GraphQL API example
project.
import { print } from "graphql"; import gql from "graphql-tag"; import { Group, GroupMember, MutationAddMemberToGroupArgs, MutationDeleteGroupArgs, MutationRemoveMemberFromGroupArgs, MutationSaveGroupArgs, QueryGroupArgs, } from "../../generated/types"; import { ResolverContext, expectGraphQLSuccessResponse, withResolverContext, } from "../../../tests/graphql.utils"; import { GRAPHQL_URI } from "../../constants"; import supertest = require("supertest"); describe("Groups", () => { it("should create", async () => { await withGroupContext(async ({ group }) => { expectGroup(group); }); }); it("should update", async () => { await withGroupContext(async (context) => { const { group } = context; if (!group.id) { throw new Error(`Group.id is undefined`); } const { id, description } = group; const newName = `Test ${new Date().toLocaleString()}`; const result = await resolverSaveGroup(context, { group: { id, description, displayName: newName, }, }); expectGroup(result); expect(result.displayName).toBe(newName); }); }); // I've left this full example call and response example in case you want // to start writing your own API integration tests using supertest. it("should get", async () => { await withGroupContext(async ({ application, authorization, group }) => { if (!group.id) { throw new Error(`Group.id is undefined`); } const queryData: { query: string; variables: QueryGroupArgs; } = { // `gql` provides IDE support by turning it into AST tree allowing // error checking and refactoring support. `print` then turns that // AST tree back into a string we can use in our API call. query: print(gql` query Group($key: ID!) { group(key: $key) { id displayName createdAt createdBy updatedAt updatedBy members { id displayName } } } `), variables: { key: group.id }, }; // Super test uses our definition of the application to spin up // on a random port and allow external API testing. const response = await supertest(application) .post(GRAPHQL_URI) .set("authorization", authorization) .send(queryData); expectGraphQLSuccessResponse(response); const result = response.body.data.group; expectGroup(result); expect(result.id).toBe(group.id); }); }); it("should get groups for the user", async () => { await withGroupContext(async ({ application, authorization, group }) => { // ... }); }); describe("membership", () => { it("should add members", async () => { await withGroupsContext( async ({ groupA, groupB, groupBEdges, groupC, groupCEdges }) => { // ... } ); }); it("should remove membership", async () => { await withGroupsContext( async ({ application, authorization, groupA, groupC, groupCEdges }) => { // ... } ); }); // Remember those clean up functions I promised? // We can hook the `afterAll` up to our context to help tear things down. afterAll(async () => { await withGroupsContext(async (context) => { const { groupB, groupC } = context; await resolverDeleteGroup(context, { id: groupB.id! }); await resolverDeleteGroup(context, { id: groupC.id! }); }); }); // Here's an example of a context function at the end of a `describe` // block. I personally find it hard to deal with these long context // functions inside `describe` and `it` blocks. I like them to be // simple to read, and abstract out nearly everything I can that isn't // expectations normally. But, it does make it impossible for other // developers to reuse these, preventing the context polution I // warned you about, and some people prefer code colocated as closely // as possible. let groupB: Group | undefined; let groupBEdges: GroupEdge[] | undefined; let groupC: Group | undefined; let groupCEdges: GroupEdge[] | undefined; // A helper context function to ensure all tests have access to the // some groups with some structure. type TWithGroupContext = ( context: ResolverContext & { groupA: Group; groupB: Group; groupBEdges: GroupEdge[]; groupC: Group; groupCEdges: GroupEdge[]; } ) => Promise<void>; const withGroupsContext = async (fn: TWithGroupContext) => { // We'll layer on top of the existing group to create two more // and make them related to the first. await withGroupContext(async (context) => { const { group: groupA } = context; // Setup B as a member if (!groupB) { groupB = await resolverSaveGroup(context, { group: { description: "Generated through resolver tests", displayName: `B - ${new Date().toLocaleString()}`, }, }); } if (!groupBEdges) { groupBEdges = await resolverAddMemberToGroup(context, { memberId: groupB._id!, targetSelection: { target: GroupTargetSelectionTarget.Existing, groupKey: groupA.id, }, }); } // Setup C as a member, and a child of B if (!groupC) { groupC = await resolverSaveGroup(context, { group: { description: "Generated through resolver tests", displayName: `C - ${new Date().toLocaleString()}`, }, }); } if (!groupCEdges) { groupCEdges = await resolverAddMemberToGroup(context, { memberId: groupC._id!, targetSelection: { target: GroupTargetSelectionTarget.Existing, groupKey: groupA.id, parentId: groupB._id, }, }); } await fn({ ...context, groupA, groupB, groupBEdges, groupC, groupCEdges, }); }); }; }); // Back outside of the `describe("membership")`, we need to clean up // our remaining group. Which should be tested all on it's own to make // sure it works. it("should delete", async () => { await withGroupContext(async (context) => { const { group } = context; if (!group.id) { throw new Error(`Group.id is undefined`); } const result = await resolverDeleteGroup(context, { key: group.id!, }); expectGroup(result); }); }); // We can hook in through `afterAll` to ensure that even if the // delete test isn't run we still clean up after ourselves. afterAll(async () => { await withGroupContext(async (context) => { const { group } = context; // We just have to try / catch try { await resolverDeleteGroup(context, { key: group.id! }); } catch (error) { // Because we would expect there to be "group not found" errors throwUnexpectedDeleteErrors(error); } }); }); }); // A helper context function to ensure all tests have access to a Group let group: Group | undefined; type TWithGroupContext = ( context: ResolverContext & { group: Group; } ) => Promise<void>; const withGroupContext = async (fn: TWithGroupContext) => { await withResolverContext(async (context) => { if (!group) { group = await resolverSaveGroup(context, { group: { description: "Generated through resolver tests", displayName: new Date().toLocaleString(), }, }); } await fn({ ...context, group }); }); };
What do you think? Is it heresy, to create local scope cache and pass it around for testing?
Do the layered context functions provide more simple abstractions for maintenance? Do
you prefer to see the context functions outside of the describe
blocks?
Send me a tweet.
I like it when people say hi.
Yes Aaron Mahnke. I stole your line. It's simple, lovely, and true. Thanks for all the stories 👻📖.