EY-Office ブログ

Apollo GraphQLに入門してみた(おまけ)、リファクタリング・テスティング

以前終了したApollo GraphQLに入門してみた(最終回)ですが、思い立ってモデルの分離するリファクタリングとテストコードを追加してみました。

このブログ記事から見たかたは、Apollo GraphQLに入門してみた(1)Apollo GraphQLに入門してみた(2)Apollo GraphQLに入門してみた(3)Apollo GraphQLに入門してみた(4)Apollo GraphQLに入門してみた(最終回) も見てくさい。

Apollo

リファクタリング

今回つくっているのはApollo GraphQLサーバーを使ったReactアプリ用バックエンドのプロトタイプです。本番のバックエンド・サーバーではモデル、GraphQLサーバーの言葉ではResolversの部分が複数、そして大きくなって行くことが予想されるのでResolversを本体から分離しておくのが良いと思われます。
いろいろと記事(主に英文)を読んでみましたが、Modularizing your GraphQL schema codeが良さそうだったので、これを採用しました。ResolversはSchemaを実装したものなので、Schemaも同一ファイルに入っていた方が良いとおもわれます。

ただし、ResolversのうちのQueryやMutationは1つのオブジェクトにマージする必要があります。Modularizing your GraphQL schema codeではマージに便利JavaScriptライブラリーlodashを使っていますが、この目的だけにlodashを使うのには抵抗があったのでオブジェクトのマージ用ライブラリーdeepmergeを使うことにしました。

Apollo GraphQLに入門してみた(2)Apollo GraphQLに入門してみた(3)に書いたコードのSchema, Resolversが以下のようなコードに分離されました。

  • index.ts(メイン)
・・・

import { typeDef as groupTypeDef, resolvers as groupResolvers,
  groupDataLoader } from './models/group';
import { typeDef as userTypeDef, resolvers as userResolvers,
  loginedUserId } from './models/user';

import deepmerge from 'deepmerge'

・・・

const schema = applyMiddleware(
  constraintDirective()(
    makeExecutableSchema({
      typeDefs: [...scalarTypeDefs, constraintDirectiveTypeDefs, typeDefs,
                 userTypeDef, groupTypeDef],
      resolvers: deepmerge.all([scalarResolvers, userResolvers, groupResolvers])
                 as Resolvers
  })),
  permisions
)
  • modeles/group.ts
import DataLoader from 'dataloader';
import { IDatabase } from "pg-promise";
import { IClient } from 'pg-promise/typescript/pg-subset';
import { Resolvers, Group } from '../generated/graphql'

export const typeDef = `
  extend type Query {
    group(id: Int!): Group
    groups: [Group]
  }

  type Group {
    id: Int!
    name: String!
    created_at: DateTime!
    updated_at: DateTime!
  }
`;

export const resolvers:Resolvers = {
  Query: {
    group: async (_parent, {id}: {id:number}, context) =>
      await context.db.one('SELECT * FROM groups WHERE id = $1', id),
    groups: async (_parent, _args , context) =>
      await context.db.any('SELECT * FROM groups')
  }
};

export type GroupLoaderType = DataLoader<number, Group[]>;
export const groupDataLoader = (db: IDatabase<{}, IClient>) =>
    new DataLoader<number, Group[]>(async (keys: readonly number[]) =>
      await db.any('SELECT * FROM groups WHERE id IN ($1:csv)', [keys]));
  • models/user.ts
import { UserInputError } from 'apollo-server-express';
import bcrypt  from 'bcrypt';

import { Resolvers, UserInput } from '../generated/graphql'
import { Context } from '../context';

const BCRYPT_SALT_ROUNDS = 10

declare module 'express-session' {
  interface SessionData {
      userId: number;
  }
}

export const typeDef = `
  extend type Query {
    user(id: Int!): User
    users: [User]
  }

  extend type Mutation {
    userLogin(email: String!, password: String!): User
    userLogout: Void
    userRegister(user: UserInput!): User
  }

  type User {
    id: Int!
    name: String!
    email: String!
    group_id: Int!
    group: Group!
    created_at: DateTime!
    updated_at: DateTime!
  }
  input UserInput {
    name: String! @constraint(minLength: 1)
    email: String! @constraint(format: "email")
    password: String! @constraint(minLength: 8)
    group_id: Int!
  }
`;

type UserLoginArgs = {email:string, password:string}
type UserRegisterArgs = {user: UserInput}

export const resolvers:Resolvers = {
  Query: {
    user: async (_parent, {id}: {id:number}, context, _info) =>
      await context.db.one('SELECT * FROM users WHERE id = $1', id),

    users: async (_parent, _args , context) =>
      await context.db.any('SELECT * FROM users'),
  },

  User: {
    group: async (parent,  _args , context) =>
      await context.loaders.group.load(parent.group_id) as any
  },

  Mutation: {
    userLogin: async (_parent, {email, password}:UserLoginArgs, context) => {
      const user = await context.db.oneOrNone('SELECT * FROM users WHERE email = $1', email)
      if (user && bcrypt.compareSync(password, user.password)) {
        context.req.session.userId = user.id
        return user
      } else {
        return null
      }
    },

    userLogout: async (_parent, _args, context) => {
      context.req.session.destroy((err: Error) => context.log("---- logout"))
    },

    userRegister: async (_parent, args:UserRegisterArgs, context) => {
      const userInput = {...args.user, password: bcrypt.hashSync(args.user.password, BCRYPT_SALT_ROUNDS)}
      const userCheck = await context.db.one('SELECT count(*) FROM users WHERE email = $1', userInput.email)
      if (Number(userCheck.count) > 0) {
        throw new UserInputError('Already registered', {argumentName: 'email'})
      }

      const sql = context.pgp.helpers.insert(userInput, null, 'users') + "RETURNING *"
      const user = await context.db.one(sql)
      return user
    }
  }
};

export const loginedUserId = (context: Context) => context.req.session.userId

テスティング

やはりテストコードも書きたくなりますよね。😁

Resolversに複雑なロジックがある場合は、Resolver単位でユニットテストを書けばよいと思いますが、現在のところ複雑なロジックは無いので、GraphQLサーバーとしてのインテグレーションテストを書こうと思いました。
いろいろ調べたところsuperagentを使うとExpressをHTTP通信レベルで簡単にテストを書く事ができます。

まず、GraphQLサーバーのコードはExpress(Apollo GraphQL)サーバーのコードserver.tsと、それをソケットに割り付けるindex.tsの分けます。

  • index.ts
import app from './server'

app.listen({ port: 5000 }, () => {
  console.log('server on http://localhost:5000/graphql');
});

テスティングフレームワークとしてはReactでは標準的なJestを使う事ににました。

テストコードは以下のようになります(データベース周りはいま1つですが)。

  1. supertestのrequest関数にExpressサーバーのインスタンスappを渡すことで、Expressサーバーにpost()、send()関数でリクエストを送れます
  2. またrequest関数の戻り値でExpressサーバーからのレスポンス(ヘッダーやデータ)が取得できます
  • test/features/logined.test.ts
import request from 'supertest'
import app, { db } from '../../src/server'
import { User, Group } from '../../src/generated/graphql'
import { getSidCookie } from '../helper'


beforeEach(async() => {
  await db.none("BEGIN")
})

afterEach(async() => {
  await db.none("ROLLBACK")
})

afterAll(async() => {
  await db.$pool.end()
})


describe("ログイン済みの場合", () => {
  let sidCookie = ""

  beforeEach(async() => {
    const res = await request(app).post("/graphql").send({
      query: `mutation {
        userLogin(email: "yama@mail.com", password: "test@123") {
          id name email
        }}`
    })
    expect(res.status).toBe(200)
    sidCookie = getSidCookie(res)
  })

  describe("Query: user(id)", () => {
    it("ユーザー情報が取得できる", async () => {
      const res = await request(app).post("/graphql").set('Cookie', sidCookie).send({
        query: `query Query {
          user(id: 1) {
            id email name
          }
        }`
      })

      expect(res.status).toBe(200)
      expect(res.body.data.user)
        .toEqual({id:1, name: "山田太郎", email: "yama@mail.com"})
    })

    it("関連情報も取得できる", async () => {
      const res = await request(app).post("/graphql").set('Cookie', sidCookie).send({
        query: `query Query {
          user(id: 1) {
            id email name
            group { id name }
          }
        }`
      })

      expect(res.status).toBe(200)
      expect(res.body.data.user)
        .toEqual({id:1, name: "山田太郎", email: "yama@mail.com", group: {id:1, name: "土"}})
    })
  })

  describe("Query: users", () => {
    it("ユーザー情報一覧が取得できる", async () => {
      const res = await request(app).post("/graphql").set('Cookie', sidCookie).send({
        query: `query Query {
          users {
            id email name
          }
        }`
      })

      expect(res.status).toBe(200)
      const users = res.body.data.users
      expect(users.length).toBe(4)
      users.sort((a:User, b:User) => a.id - b.id)
      expect(users.map((u:User) => u.name)).toEqual(["山田太郎", "岡田太郎", "川田太郎","海田太郎"])
    })

    it("関連情報も取得できる", async () => {
      const res = await request(app).post("/graphql").set('Cookie', sidCookie).send({
        query: `query Query {
          users {
            id email name
            group { id name }
          }
        }`
      })

      expect(res.status).toBe(200)
      const users = res.body.data.users
      users.sort((a:User, b:User) => a.id - b.id)
      expect(users.map((u:User) => `${u.name}:${u.group.name}`))
        .toEqual(["山田太郎:土", "岡田太郎:土", "川田太郎:水", "海田太郎:水"])
    })
  })

  describe("Query: group(id), groups", () => {
    it("グループ情報一覧が取得できる", async () => {
      const res = await request(app).post("/graphql").set('Cookie', sidCookie).send({
        query: `query Query {
          groups {
            id name
          }
        }`
      })

      expect(res.status).toBe(200)
      const groups = res.body.data.groups
      expect(groups.length).toBe(2)
      groups.sort((a:Group, b:Group) => a.id - b.id)
      expect(groups.map((u:Group) => u.name)).toEqual(["土", "水"])
    })

    it("グループ情報が取得できる", async () => {
      const res = await request(app).post("/graphql").set('Cookie', sidCookie).send({
        query: `query Query {
          group(id: 1) {
            id name
          }
        }`
      })

      expect(res.status).toBe(200)
      expect(res.body.data.group).toEqual({id:1, name: "土"})
    })
  })

  describe("Mutation: userLogout", () => {
    let res: request.Response
    beforeEach(async() => {
      res = await request(app).post("/graphql").set('Cookie', sidCookie).send({
        query: `mutation {
          userLogout
        }`
      })
    })

    it("ログアウトが成功する", async () => {
      expect(res.status).toBe(200)
      expect(res.body.errors).toBeFalsy()
    })

    it("SessionIDが無効になっている", async () => {
      const res2 = await request(app).post("/graphql").set('Cookie', sidCookie).send({
        query: `query Query {
          users {
            id email name
          }
        }`
      })
      expect(res2.status).toBe(200)
      expect(res2.body.errors[0].message).toContain("Not Authorised")
    })
  })
})

const getSidCookie = (response: request.Response): string =>
  response.header['set-cookie']?.[0].match(/(connect.sid=.*?);/)?.[1] ?? ""
  • test/features/not_login.test.ts
import exp from 'constants'
import request from 'supertest'
import app, { db } from '../../src/server'
import { getSidCookie } from '../helper'

beforeEach(async() => {
  await db.none("BEGIN")
})

afterEach(async() => {
  await db.none("ROLLBACK")
})

afterAll(async() => {
  await db.$pool.end()
})

describe("未ログインの場合", () => {
  describe("Mutation: userLogin(email, password)", () => {

    describe("正しいemail,passwordの場合", () => {
      let res: request.Response

      beforeEach(async() => {
        res = await request(app).post("/graphql").send({
          query: `mutation {
            userLogin(email: "yama@mail.com", password: "test@123") {
              id name email
            }}`
        })
      })

      it("成功し、ユーザー情報が戻る", () => {
        expect(res.status).toBe(200)
        const userLogin = res.body.data.userLogin
        expect(userLogin.email).toBe("yama@mail.com")
        expect(userLogin.name).toBe("山田太郎")
      })
      it("成功した場合はCookieにSessionIDが入っている", () => {
        expect(res.status).toBe(200)
        expect((getSidCookie(res)).length).toBeGreaterThan(10)
      })
    })

    describe("正しくないpasswordの場合", () => {
      let res: request.Response

      beforeEach(async() => {
        res = await request(app).post("/graphql").send({
          query: `mutation {
            userLogin(email: "yama@mail.com", password: "test@111") {
              id name email
            }}`
        })
      })

      it("失敗し、ユーザー情報はnull", () => {
        expect(res.status).toBe(200)
        expect(res.body.data.userLogin).toBeNull()
      })
      it("CookieにSessionIDが入って", () => {
        expect(res.status).toBe(200)
        expect((getSidCookie(res))).toBe("")
      })
    })
  })

  describe("Mutation: userRegister(user)", () => {
    it("ユーザー登録が出来る", async() => {
      const userInput = `{
        email: "taro@mail.com",
        group_id: 1,
        name: "吉田太郎",
        password: "test@123"
      }`
      const res = await request(app).post("/graphql").send({
        query: `mutation {
          userRegister(user: ${userInput}) {
            id name email
          }}`
      })
      expect(res.status).toBe(200)
      const userRegister = res.body.data.userRegister
      expect(userRegister.email).toBe("taro@mail.com")
      expect(userRegister.name).toBe("吉田太郎")
    })

    it("正しくない情報では、ユーザー登録が出来ない", async() => {
      const userInput = `{
        email: "taro",
        group_id: 1,
        name: "吉田太郎",
        password: "test@123"
      }`
      const res = await request(app).post("/graphql").send({
        query: `mutation {
          userRegister(user: ${userInput}) {
            id name email
          }}`
      })
      expect(res.status).toBe(200)
      expect(res.body.errors[0].message).toContain("Must be in email format")
    })

    it("既に要録されているemailでは、ユーザー登録が出来ない", async() => {
      const userInput = `{
        email: "yama@mail.com",
        group_id: 1,
        name: "山田太郎",
        password: "test@123"
      }`
      const res = await request(app).post("/graphql").send({
        query: `mutation {
          userRegister(user: ${userInput}) {
            id name email
          }}`
      })
      expect(res.status).toBe(200)
      expect(res.body.errors[0].message).toContain("Already registered")
    })
  })

  describe("Query: users", () => {
    it("失敗する", async () => {
      const res = await request(app).post("/graphql").send({
        query: `query Query {
          users {
            email name
          }
        }`
      })
      expect(res.status).toBe(200)
      expect(res.body.errors[0].message).toContain("Not Authorised")
    })
  })
})

こんな感じで全APIのテストコードを書けました。😁

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ