Chapter 16

Testing GraphQL APIs

Unit Test Resolver



Step 1: Run the Test

Running tests in watch mode using npm run test:watch allows for continuous feedback during development by monitoring changes to the codebase and executing related tests. If an error arises concerning SongService dependencies during the test of song.resolver.spec.ts, it suggests that the testing environment may not be correctly simulating the application's service layer, or the service itself may not be properly mocked or instantiated. Regularly updating the test configurations to reflect new dependencies prevents such issues from disrupting the development workflow.

Here is your default song.resolver code structure

import { Test, TestingModule } from "@nestjs/testing";

import { SongResolver } from "./song.resolver";



describe("SongResolver", () => {

  let resolver: SongResolver;



  beforeEach(async () => {

    const module: TestingModule = await Test.createTestingModule({

      providers: [SongResolver],

    }).compile();



    resolver = module.get<SongResolver>(SongResolver);

  });



  it("should be defined", () => {

    expect(resolver).toBeDefined();

  });

});



Running the test file in question will result in an error. This indicates a potential issue with the configuration or the environment setup for the tests, which necessitates investigation and correction to proceed with successful test execution.

SongResolver

    ✕ should be defined (34 ms)



  ● SongResolver › should be defined



    Nest can't resolve dependencies of the SongResolver (?). Please make sure that the argument SongService at index [0] is available in the RootTestModule context.



    Potential solutions:

    - Is RootTestModule a valid NestJS module?

    - If SongService is a provider, is it part of the current RootTestModule?

    - If SongService is exported from a separate @Module, is that module imported within RootTestModule?

      @Module({

        imports: [ /* the Module containing SongService */ ]

      })



Step 2: Mock the SongService

In the context of NestJS and testing, mocking services is essential for isolating units of work and ensuring tests run reliably and efficiently. The implementation of a mock SongService would involve creating a simplified version of the service, simulating its behavior and responses, which allows for comprehensive testing of components that depend on it without the overhead of actual database operations.

Mock implementations should closely mimic the actual service logic, providing predictable and controlled outputs for test scenarios. Additionally, it's advisable to keep the mock implementations updated in tandem with their respective services to ensure consistency in test behavior. This approach enhances test reliability and reflects the application's behavior in a controlled environment.

providers: [

        SongResolver,

        {

          provide: SongService,

          useValue: {

            getSongs: jest

              .fn()

              .mockResolvedValue([{ id: 'a uuid', title: 'Dancing Feat' }]),

            getSong: jest.fn().mockImplementation((id: string) => {

              return Promise.resolve({ id: id, title: 'Dancing' });

            }),

            createSong: jest

              .fn()

              .mockImplementation((createSongInput: CreateSongInput) => {

                return Promise.resolve({ id: 'a uuid', ...createSongInput });

              }),

            updateSong: jest

              .fn()

              .mockImplementation((updateSongInput: UpdateSongInput) => {

                return Promise.resolve({ affected: 1 });

              }),



            deleteSong: jest.fn().mockImplementation((id: string) => {

              return Promise.resolve({ affected: 1 });

            }),

          },

        },

      ],



The practice of crafting mock implementations for services within controllers is a strategic step for testing and development purposes. By simulating the behavior of the SongService in the SongController, one can validate the interaction and logic without the need for the actual service to be implemented or available.

The update from CreateSongDTO to CreateSongInput and UpdateSongDTO to UpdateSongInput reflects a typical progression towards a more structured and type-safe codebase. This change is often indicative of optimizing the code to align with advanced patterns like GraphQL, where Input types are commonly used to define the shape of data for mutations, enhancing the code maintainability and type checking.

When the test is run, it will pass now.

Step 3: Test getSongs from the Song Resolver

Testing of resolvers is a strategic approach to verify that the GraphQL query handling is performing as expected. For the getSongs resolver test, the goal is to confirm that the resolver accurately fetches and returns the correct set of song data, which is a crucial part of ensuring the GraphQL API's reliability.

A well-constructed test for a resolver, like getSongs, not only assesses the returned value against expected results but also encapsulates the performance of underlying services and components. It's a proactive measure to guard against regressions in the API's behavior, especially after updates or refactoring, ensuring that the end-user's queries receive consistent and accurate responses.

it("should fetch the songs", async () => {

  const songs = await resolver.getSongs();

  expect(songs).toEqual([{ id: "a uuid", title: "Dancing Feat" }]);

  expect(songs.length).toBe(1);

});



Step 4: Unit Test createSong from the Song Resolver

This is similar to getSongs above.

it("should create new song", async () => {

  const song = await resolver.createSong({ title: "Animals" });

  expect(song).toEqual({ id: "a uuid", title: "Animals" });

});



Step 5: Unit Test updateSong from the Song Resolver

The updateSong method within the Song Resolver would have a dedicated test that mocks dependencies, inputs a known entity, and asserts that the method returns the updated song object correctly.

it("should update the song", async () => {

  const song = await resolver.updateSong("a uuid", { title: "DANCING FEAT" });

  expect(song.affected).toBe(1);

});



Step 6: Test deleteSong from the Song Resolver

Similarly, testing the deleteSong method requires setting up a scenario where a song is marked for deletion, invoking the method, and subsequently verifying that the method effectively removes the target entity from the system. This would typically involve checking for the absence of the song's identifier in the dataset post-operation. This is important for maintaining data integrity and confirming that the application logic conforms to requirements. Adding mock data that resembles real-world cases and asserting on both the returned value and the final state of the dataset are additional layers that enhance test effectiveness.

it("should delete the song", async () => {

  const song = await resolver.deleteSong("a uuid");

  expect(song.affected).toBe(1);

});





End to End Test



Step 1: Adding End to End watch script to package.json file

Adding an End-to-End (E2E) watch script to the package.json file allows for the continuous observation and testing of the application as changes are made, facilitating immediate feedback and efficient issue detection. This script is configured to run the E2E testing suite in a watch mode, which monitors for file changes and automatically reruns tests, proving essential for iterative development and enhancing productivity.

The integration of a watch mode for E2E tests into the development workflow can significantly improve code quality by enabling developers to identify and resolve integration issues as they code. It acts as a real-time safeguard against regressions and defects, which is a reflection of high standards in software development and maintenance.

"test:e2e:watch": "jest --watch --detectOpenHandles --config ./test/jest-e2e.json"



Step 2: Create a new E2E Testing File

You can have to create a new testing file in the test/song/song.e2e-spec.ts

Step 3: Setup a E2E Testing File

import { Test, TestingModule } from "@nestjs/testing";

import { INestApplication } from "@nestjs/common";

import * as request from "supertest";

import { AppModule } from "../song/../../src/app.module";

import { TypeOrmModule } from "@nestjs/typeorm";

import { Song } from "../song/../../src/song/song.entity";

import { SongModule } from "../song/../../src/song/song.module";

import { CreateSongDTO } from "../song/../../src/song/dto/create-song-dto";



describe("Song Resolver (e2e)", () => {

  let app: INestApplication;



  beforeEach(async () => {

    const moduleFixture: TestingModule = await Test.createTestingModule({

      imports: [AppModule],

    }).compile();



    app = moduleFixture.createNestApplication();

    await app.init();

  });



  beforeAll(async () => {

    const moduleRef = await Test.createTestingModule({

      imports: [

        TypeOrmModule.forRoot({

          type: "postgres",

          url: "postgres://postgres:root@localhost:5432/test-dev",

          synchronize: true,

          entities: [Song],

          dropSchema: true,

        }),

        SongModule,

      ],

    }).compile();



    app = moduleRef.createNestApplication();

    await app.init();

  });



  afterEach(async () => {

    const songRepository = app.get("SongRepository");

    await songRepository.clear();

  });



  afterAll(async () => {

    await app.close();

  });



  const createSong = (createSongDTO: CreateSongDTO): Promise<Song> => {

    const song = new Song();

    song.title = createSongDTO.title;

    const songRepo = app.get("SongRepository");

    return songRepo.save(song);

  };



  it("/ (GET)", () => {

    return request(app.getHttpServer())

      .get("/")

      .expect(200)

      .expect("Hello World!");

  });

});



To ensure test accuracy and environment isolation, the Songs table in the database must be cleared after each test is executed. This practice prevents state leakage between tests and maintains consistency.

The file to initiate End-to-End testing in watch mode is executed with the command npm run test:e2e:watch. This initiates a process that continually monitors for any changes in the codebase and reruns tests accordingly, allowing for immediate feedback and efficient debugging.

Step 4: Test the Songs Query

it("(Query) it should get all songs with songs query", async () => {

  const newSong = await createSong({ title: "Animals" });

  const queryData = {

    query: `query {

        songs {

          id

          title

        }

      }`,

  };

  const results = await request(app.getHttpServer())

    .post("/graphql")

    .send(queryData);



  expect(results.statusCode).toBe(200);

  expect(results.body).toEqual({ data: { songs: [newSong] } });

});



  1. Testing queries in Nest.js, especially when involving a database, begins by ensuring that the relevant records exist to be queried against. The setup phase of a test typically includes invoking methods like createSong to insert necessary data into the Songs table, resulting in a controlled and predictable testing environment.

  2. The formation of GraphQL queries is facilitated through the use of structured variables, such as queryData, which hold the GraphQL query string. These queries are then utilized to interact with the GraphQL API, which, by convention, operates under a single endpoint, typically /graphql.

  3. To execute these queries within a test suite, the query data must be provided to the send method of the testing tool. This simulates a client request to the server, allowing for the testing of query handling and response accuracy.

  4. In high-quality software engineering, a tip is to ensure the testing environment closely mimics production conditions without affecting real data, maintaining isolation between tests for reliability. Additionally, structuring test cases to reflect a variety of realistic scenarios can uncover edge cases and potential bugs before deployment.

Step 5: Test A Single Song Query

it("(Query) it should get a song by id", async () => {

  const newSong = await createSong({ title: "Animals" });

  const queryData = {

    query: `query GetSong($id: ID!){

        song(id: $id){

          title

          id

        }

      }`,

    variables: {

      id: newSong.id,

    },

  };

  const results = await request(app.getHttpServer())

    .post("/graphql")

    .send(queryData)

    .expect(200);



  expect(results.body).toEqual({ data: { song: newSong } });

});



  1. Defining a Query in GraphQL with specific operation names and required variables, such as GetSong($id: ID!), clarifies the expected input and makes the query reusable. Variables in GraphQL, like $id, are used to parameterize queries, allowing for dynamic input that can be varied with each query execution.

  2. Supplying the $id variable within a song query allows for the specification of which particular song is being requested. This is a part of query composition in GraphQL, where the query variables are declared and then used within the query body to fetch data.

  3. The variables section in a GraphQL operation is where the actual values for the declared variables are specified. This is where one sets the specific ID value for $id when executing the query, thereby allowing for the retrieval of data corresponding to that particular identifier.

Step 6: Test The Creation of a aong mutation

it("(Mutation) it should create a new song", async () => {

  const queryData = {

    query: `mutation CreateSong($createSongInput: CreateSongInput!){

        createSong(createSongInput: $createSongInput){

          title

          id

        }

      }`,

    variables: {

      createSongInput: {

        title: "Animals",

      },

    },

  };

  const results = await request(app.getHttpServer())

    .post("/graphql")

    .send(queryData)

    .expect(200);



  expect(results.body.data.createSong.title).toBe("Animals");

});



Step 7: Testing The Update Song Mutation

The testing of the 'Create Song Mutation' in Nest.js involves invoking the mutation responsible for creating a new song entry and verifying that the operation successfully adds the song to the application state or database. The test ensures that the mutation receives the correct input and produces the expected song record, consistent with defined data schemas and business logic.

it("(Mutation) it should update existing song", async () => {

  const newSong = await createSong({ title: "Animals" });

  const queryData = {

    query: `mutation UpdateSong($id: ID!, $updateSongInput: UpdateSongInput!){

        updateSong(id: $id, updateSongInput: $updateSongInput){

          affected

        }

      }`,

    variables: {

      id: newSong.id,

      updateSongInput: {

        title: "Lover",

      },

    },

  };

  const results = await request(app.getHttpServer())

    .post("/graphql")

    .send(queryData)

    .expect(200);



  expect(results.body.data.updateSong.affected).toBe(1);

});



Step 8: Test The Delete Song Mutation

Testing the 'Update Song Mutation' entails executing the mutation that modifies an existing song entry and confirming that the changes are accurately persisted. This test asserts that the mutation not only accepts valid input but also integrates seamlessly with the underlying data handling layers, effectively maintaining data integrity throughout the operation.

it("(Mutation) it should delete existing song", async () => {

  const newSong = await createSong({ title: "Animals" });

  const queryData = {

    query: `mutation DeleteSong($id: ID!){

        deleteSong(id: $id){

          affected

        }

      }`,

    variables: {

      id: newSong.id,

    },

  };

  const results = await request(app.getHttpServer())

    .post("/graphql")

    .send(queryData)

    .expect(200);



  expect(results.body.data.deleteSong.affected).toBe(1);

});



For optimal results, these tests would typically include checks for handling of invalid inputs and edge cases to ensure resilience. Leveraging a suite of such mutation tests establishes a safeguard against regressions and aids in maintaining a stable and reliable codebase as new features are developed.


$7 bundle of my best NestJS + backend developer Courses.
More details coming soon.

Get the latest insights from the marketing world.

A blog that focuses on providing practical tips and strategies for businesses to improve their marketing and sales efforts.

Solutions

Helping you help your customers.

Sell smarter, better, and faster.

The insights you need to make smarter business decisions.