TTTDD - Types then Test Driven Development
I wanted to experiment using tdd with nestjs and graphql. I’ve worked with graphql a little before and nestjs not at all. This post won’t really have a conclusion, sales pitch or any analysis but I wanted to document an approach that worked for me. I’m nicknaming it “types then test driven development”.
The setup
I decide for the experiment I would create a small api which stores and queries blogposts via graphql. There’s a github repo meadsteve/blogpost-tttdd-nestjs-graphql with the full code for this experiment. I’ll link to relevant commits along the way.
I started with nestjs default project template from nest new --strict {path}
(see commit 69e25be).
I’m using wallabyjs as a test runner because I love getting rapid feedback in my IDE. It can work automatically with a jest based project but I added a little bit of configuration so that it found the end to end tests created by nest as well as the inline spec files (commit f357595):
// create wallaby.js
module.exports = function (wallaby) {
return {
autoDetect: true,
files: ['src/**/*.ts', { pattern: 'src/**/*.spec.ts', ignore: true }],
tests: ['src/**/*.spec.ts', 'test/**/*.e2e-spec.ts'],
env: {
type: 'node',
},
};
};
Wallaby now shows me 0 failing tests, 2 passing
, these are the two tests that come from the project template so
I now know everything is configured correctly.
Adding graphql
Next I needed to setup graphql for the project so I installed the apollo server integration for nextjs
npm i @nestjs/graphql @nestjs/apollo graphql apollo-server-express
and modified app.module.ts
to load it (commit 4c5e06e):
// src/app.module.ts
//...
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
}),
]
//...
but now wallaby tells me one of my tests is failing:
test/app.e2e-spec.ts AppController (e2e) / (GET) [41 ms]
Error: Apollo Server requires either an existing schema, modules or typeDefs
The end to end test created by nest is failing because apollo expects something to do with graphql.
Setup some types
This is where I decided to do types first then tests
. I setup a module:
nest g module blogposts
I then thought about what the smallest useful type would be for a blogpost. I decided on a post with just a title and content (more can be added later):
// src/blogposts/models/blogpost.model.ts
@ObjectType()
export class BlogPost {
@Field()
title: string;
@Field()
content: string;
}
and I thought it would make sense if graphql could return all the blogposts, so I created a resolver with a method for this:
// src/blogposts/blogposts.resolver.ts
@Resolver((of) => BlogPost)
export class BlogpostsResolver {
@Query((returns) => [BlogPost])
async blogposts(): Promise<BlogPost[]> {
throw new NotImplementedException('CODE ME!');
}
}
Importantly this doesn’t have any logic yet but the shape of the code seems right and my tests are back to passing (commit 414e792 has the code to get to this point):
0 failing tests, 2 passing
The first real end to end test
The very first real thing I wanted was to test that initially there are no blogposts. Since the graphql server was up and running I could use the playground to generate a query to get this:
query {
blogposts{title, content}
}
running this gave me the following error response. Which makes sense because I didn’t write any logic.
{
"errors": [
{
"message": "CODE ME!",
"extensions": {
"code": "501",
"response": {
"statusCode": 501,
"message": "CODE ME!",
"error": "Not Implemented"
}
}
}
],
"data": null
}
I created a new test file test/graphql/blogposts-graphql.e2e-spec.ts
and wrote a test to assert that running the query
for blogposts returns an empty list:
describe('Blog Posts (graphql e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('returns no posts when we dont have any', async () => {
const query = `query{
blogposts {title, content}
}`;
const { body } = await request(app.getHttpServer()).post('/graphql').send({
query: query,
});
expect(body.data.blogposts).toEqual([]);
});
});
Then I made the smallest change I could think to make to the test pass (see commit b34b4f4).
// src/blogposts/blogposts.resolver.ts
@Resolver((of) => BlogPost)
export class BlogpostsResolver {
@Query((returns) => [BlogPost])
async blogposts(): Promise<BlogPost[]> {
return [];
}
}
More types and the second (much more useful) test
Always returning an empty list, whilst technically correct, is not very useful. I wanted to write another end to end test case. This one would create a blogpost and then check that it is returned in the list of all blogposts. But first I needed to go back to my types and make a mutation available to create a new blogpost.
I added to my blog posts resolver and as with the previous change I ignored logic for now and just got the shape right. I would take in title and content strings and create a blogpost.
// src/blogposts/blogposts.resolver.ts
export class BlogpostsResolver {
// ... existing code ...
@Mutation((returns) => BlogPost, { name: 'blogpost' })
async createBlogPost(@Args('title') title: string, @Args('content') content: string): Promise<BlogPost> {
throw new NotImplementedException('CODE ME');
}
}
Going back to the graphql playground I can then build a mutation like this to add a post:
mutation {
blogpost(title: "First post", content: "weccome to my blog") {title, content}
}
It will fail, as the previous query did at first, because it has no implementation but I can use this mutation to build a test for the feature I want (the test is a little long so I ended up refactoring it in a later commit).
it('can post a blogpost and then see that its in the list', async () => {
// Create the post
const creationMutation = `mutation {
blogpost(title: "First post", content: "welcome to my blog") {title, content}
}`;
await request(app.getHttpServer()).post('/graphql').send({
query: creationMutation,
});
// Check that the post is found in the list
const query = `query{
blogposts {title, content}
}`;
const { body } = await request(app.getHttpServer()).post('/graphql').send({
query: query,
});
expect(body.data.blogposts).toContainEqual({
title: 'First post',
content: 'welcome to my blog',
});
});
With this failing end to end test I started work on implementing some actual storage. For the purpose of this experiment I wrote a class to store the list “in-memory”. This storage class is useful for tests but before I’d put this into production I’d need to swap it out for something more permanent. With this in mind I registered the provider as an interface with my in-memory implementation as the default for now.
// src/blogposts/blogposts.module.ts
@Module({
//...
providers: [
BlogpostsResolver,
{
provide: 'BlogpostStorage',
useClass: InMemoryBlogpostStorage,
},
]
//...
})
export class BlogpostsModule {}
Now that this storage was available to nest I updated my resolver to use it (see commit 41e95eb):
@Resolver((of) => BlogPost)
export class BlogpostsResolver {
constructor(@Inject('BlogpostStorage') private storage: BlogpostStorage) {}
@Query((returns) => [BlogPost])
async blogposts(): Promise<BlogPost[]> {
return this.storage.getAllPosts();
}
@Mutation((returns) => BlogPost, { name: 'blogpost' })
async createBlogPost(
@Args('title') title: string,
@Args('content') content: string,
): Promise<BlogPost> {
const newPost = { title, content };
this.storage.addNewPost(newPost);
return newPost;
}
}
After writing this implementation I saw that wallaby showed me all tests passing (note: it’s jumped to 6 because my storage class has some tests of its own).
0 failing tests, 6 passing
This is where I’m stopping the experiment for now. But with this way of working I’m confident that I should be able to continue looping between “writing some typed code”, “writing a test”, and “making the test pass” to add any other features I want.