以前終了したApollo GraphQLに入門してみた(最終回)ですが、思い立ってモデルの分離するリファクタリングとテストコードを追加してみました。
このブログ記事から見たかたは、Apollo GraphQLに入門してみた(1)、Apollo GraphQLに入門してみた(2)、Apollo GraphQLに入門してみた(3)、Apollo GraphQLに入門してみた(4)、Apollo GraphQLに入門してみた(最終回) も見てくさい。
リファクタリング
今回つくっているのは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つですが)。
- supertestのrequest関数にExpressサーバーのインスタンスappを渡すことで、Expressサーバーにpost()、send()関数でリクエストを送れます
- また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のテストコードを書けました。😁