DocsGuidesGitHub

Backend

Our approach to testing is to focus on the most important things first. Focus on integration testing that covers most of the code and also resembles how the code is used in production. Let's see it in action.

This is the test we provide in every Saruni instance.

ts
import { createApiTestContext } from "@saruni/test";
import { db } from "../src/db";
describe("Hello test", () => {
const ctx = createApiTestContext(db);
test("Should return the 'Hello, Saruni!'", async () => {
const result = await ctx.executeGraphql(`
{
hello
}`);
expect(result.data.hello).toBe("Hello, Saruni!");
});
});

We need to pass our db instance into the createApiTestContext that will return our context object. On that context object we get back our db and two more functions: executeGraphql and setContext.

Let's get into executeGraphql.

ExecuteGraphql

This function is just a wrapper around the graphql function provided by the js implementaion of graphql. It does stitch our schema together and abstract away some of the repetative code.

We can pass graphql documents into the function and itt will run the appropriate resolver for us. Considering our resolver looks like this:

ts
export const Query = {
hello: () => {
return "Hello, Saruni!";
},
};

We can expect this test to pass:

ts
test("Should return the 'Hello, Saruni!'", async () => {
const result = await ctx.executeGraphql(`
{
hello
}`);
expect(result.data.hello).toBe("Hello, Saruni!");
});

What is missing?

Before going further into more testing it is worth mentioning what is not being tested. We test the db layer, the graphql layer but not the network layer with this approach. That part we need to mimick. This is certanly a tradeoff. In the case of graphql this might not be a problem since our lambda handler function is provided by apollo. We don't want to test apollo. They did that already.

But in case of other lambdas the network layer becomes more interesting. Not to mention that invoking a lambda can be triggered by other events, not just http requests. We can use sns, sqs, s3 events, cron jobs and so on. With time hopefully we can provide solutions for that.

Resolver with db access

In our schema.prisma file we created a model called Message.

js
model Message {
id Int @default(autoincrement()) @id
message String
}

Let's create another resolver next to our hello one.

ts
export const Query = {
hello: () => {
return "Hello, Saruni!";
},
hellos: async () => {
return (await db.message.findMany({})).map((message) => {
return message.message;
});
},
};

This will return an array of string, that is represented like this in our typeDefs:

js
type Query {
hello: String!
hellos: [String]!
}

Now, there are a few problems. First, we need to wipe the db on every call to yarn sr test. Second we have no messages in the db. So this test will fail:

ts
import { createApiTestContext } from "@saruni/test";
import { db } from "../src/db";
describe("Hellos test", () => {
const ctx = createApiTestContext(db);
test("Should return the ['ola', 'hy']", async () => {
const result = await ctx.executeGraphql(`
{
hellos
}`);
expect(result.data.hellos).toEqual(expect.arrayContaining(["ola", "hy"]));
});
});

The first issue is covered by Saruni. Every time you start testing it will drop the test database schema and apply all your migrations.

Tip: If you just a made a migration don't forget to save it with yarn sr db migrate save. The test command only applies already made migrations. It does not save them.

So it drops the db schema and then migrates your db. But we still have the issue of putting predefined data.

Seeding is covered in a global level in Saruni and its scope is not limited to testing environment.

We can choose that way. In that case we need to modify our api/db/seed.ts file.

ts
import { db } from ".";
export const seed = async () => {
try {
await db.message.create({ data: { message: "ola" } });
await db.message.create({ data: { message: "hy" } });
} catch (e) {
console.log(JSON.stringify(e));
} finally {
await db.$disconnect();
}
};

Tip: make sure you await for the db transactions as they are promised based.

If we think taking care of seeding is better in our test suite we can do that there as well.

ts
import { createApiTestContext } from "@saruni/test";
import { db } from "../src/db";
describe("Hellos test", () => {
const ctx = createApiTestContext(db);
beforeAll(async () => {
await ctx.db.message.create({ data: { message: "ola" } });
await ctx.db.message.create({ data: { message: "hy" } });
});
test("Should return the ['ola', 'hy']", async () => {
const result = await ctx.executeGraphql(`
{
hellos
}`);
expect(result.data.hellos).toEqual(expect.arrayContaining(["ola", "hy"]));
});
});

This achieves the same thing. The only difference that this approach only applies to this test suite while the global seeding is to every test suite.

Tip: Using the db as direct import and ctx.db achieves the same thing. Altough in later relases we might intercept db (just as we do with executGraphl and graphql). It is adviced to use db from the context object.

## variables

Life is not always that easy. Our queries and mutation might need variables. Let's modify our hellos query to use some optional arguments.

js
// in resolvers
export const Query = {
hello: () => {
return "Hello, Saruni!";
},
hellos: async (_root, args) => {
let prismaArguments = {};
if (args.input && args.input.take) {
prismaArguments.take = args.input.take;
}
return (await db.message.findMany(prismaArguments)).map((message) => {
return message.message;
});
},
};
// in typdefs
input HellosInput {
take: Int!
}
type Query {
hello: String!
hellos(input: HellosInput): [String]!
}

That little modification should allow us to use optinal take arguemnt. When we do, we ask prisma to only return a the first x element.

Let's test this out.

ts
import { createApiTestContext } from "@saruni/test";
import { db } from "../src/db";
describe("Hellos test", () => {
const ctx = createApiTestContext(db);
test("Should return the ['ola', 'hy']", async () => {
const result = await ctx.executeGraphql(`
{
hellos
}`);
expect(result.data.hellos).toEqual(expect.arrayContaining(["ola", "hy"]));
});
test("Should return the ['ola'] when take 1 is provided", async () => {
const result = await ctx.executeGraphql(
`
query Hellos ($input: HellosInput) {
hellos (input: $input)
}`,
{ variables: { input: { take: 1 } } }
);
expect(result.data.hellos).toEqual(expect.arrayContaining(["ola"]));
});
});

In the second test we assume it is only the "ola" that will be returned, if we specify take 1.

executagraoqhl takes a second arguemnt. There you can define variables just as we did and graphql will make our query.

Context

Our last piece on testing will be context. Graphql contract comes with the context idea which is where we can attach any arbitrary data to our resolver.

As I mentioned earlier, we don't test the network layer with this approach. Much of the content attached to the data comes from the network layer. Headers, cookies, aws identifiers and so on. So it is crucial to mock the context in some cases.

One of this case is the authentication, as we set it up. Our jwt based authentication is a higher order function that reads the headers object on the context.

js
const withAuthentication = (context) => {
context.headers["Authorizarion"] // assumes the headers are attached
}

Since there is no network layer in our tests we need to take matters into our own hands.

Just to see our test actually working, let's wrap out hellos query in withAuthentication.

ts
import { db } from "./../../../db";
import { withAuthentication } from "@saruni/auth";
export const Query = {
hello: () => {
return "Hello, Saruni!";
},
hellos: withAuthentication(async (_root, args) => {
let prismaArguments = {};
if (args.input && args.input.take) {
prismaArguments.take = args.input.take;
}
return (await db.message.findMany(prismaArguments)).map((message) => {
return message.message;
});
}),
};

Running the tests will fail with Authentication Error. Expected.

We provide a createAccessToken in the @saruni/auth package function to generate jwt tokens using the secret provided in the .env files.

Setting the context

There are two ways to set the context. One is on the executeGraphql function. The second argument to that function can take a variables arugment as we have seen already and optionally a context.

ts
test("Should return the ['ola'] when take 1 is provided", async () => {
const result = await ctx.executeGraphql(
`
query Hellos ($input: HellosInput) {
hellos (input: $input)
}`,
{
variables: { input: { take: 1 } },
context: {
headers: {
Authorization: createAccessToken({}),
},
},
}
);
expect(result.data.hellos).toEqual(expect.arrayContaining(["ola"]));
});

That solution works fine and makes the test pass. There is another issue here. Context can be the same across many request. Just like we have the same auth headers. So it might make sense to actually put the context into the testContext and not set it up on every graphql request.

We can do that by:

ts
const ctx = createApiTestContext(db);
beforeAll(async () => {
ctx.setGraphQLContext({
headers: {
Authorization: createAccessToken({}),
},
});
});

The setGraphQLContext sets the context, so every executeGraphql function will be using it by default. Unless that is overwritten by their own context. That always takes a priority.

So in this case:

ts
import { createApiTestContext } from "@saruni/test";
import { createAccessToken } from "@saruni/auth";
import { db } from "../src/db";
describe("Hellos test", () => {
const ctx = createApiTestContext(db);
beforeAll(async () => {
ctx.setGraphQLContext({
headers: {
headerWithSet: "one"
},
});
});
test("Should return the ['ola'] when take 1 is provided", async () => {
const result = await ctx.executeGraphql(
`
query Hellos ($input: HellosInput) {
hellos (input: $input)
}`,
{
variables: { input: { take: 1 } },
context: {
headerWithGraphql: "two"
}
}
);
expect(result.data.hellos).toEqual(expect.arrayContaining(["ola"]));
});
});

This case the content of the context will be:

ts
{
headerWithGraphql: "two",
headerWithSet: "one"
}

Sum

That is basics of testing. The possibilites are endless we were just focusing on providing the tools to create quick integration tests that resembles actual usage.