一个简单的Koa电影接口应用的实现与测试
基本需求
这个文章想用Koa写一个比较简单的、具有基本CRUD功能的电影评分系统。大概提供几个接口:返回所有电影信息、新增电影、修改电影详情和删除某电影详情的功能。
创建一个基本服务
代码step1
上一篇文章讲了,Koa基本就是对Node.js的http包做了一下包装,让创建服务这件事比较方便了。但对一些像路由、解析POST请求等需求还是需要一些额外的工具的。先来创建一个新项目:
mkdir movie-api && cd movie-api
npm init -y
npm i koa @koa/router koa-bodyparser mysql2 -S
新建app.js
const Koa = require('koa')
const Router = require('@koa/router')
const router = new Router()
const bodyParser = require('koa-bodyparser')
const app = new Koa()
router.get('/', ctx => {
ctx.body = 'server ok'
})
app.use(bodyParser())
.use(router.routes())
.listen(3000, () => console.log('server is running at 3000'))
运行一下这个文件,就得到了一个基本的Koa服务。
加上数据库
这次要用从真的数据库里取数据返给请求处,所以创建一个数据库movie_db
,添加一些数据:
CREATE DATABASE IF NOT EXISTS `movie_db` DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci;
USE `movie_db`;
CREATE TABLE `movie` (
`id` int(11) NOT NULL PRIMARY KEY AUTO_INCREMENT,
`name` text COLLATE utf8_unicode_ci NOT NULL,
`year` text COLLATE utf8_unicode_ci NOT NULL
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
INSERT INTO `movie` (`id`, `name`, `year`) VALUES
(1, '星际穿越', '2014'),
(2, '大腕', '2001'),
(3, '飞驰人生', '2019'),
(4, '成为詹姆斯·邦德:丹尼尔·克雷格的故事', '2021'),
(5, '空前绝后满天飞', '1980'),
(6, '007:大破天幕杀机', '2012'),
(7, '黑客帝国', '1999'),
(8, '哈利·波特与魔法石', '2001'),
(9, '流浪地球', '2019'),
(10, '疯狂动物城', '2016'),
(11, '惊异大奇航', '1987'),
(12, '爱乐之城', '2016'),
(13, '谍影重重', '2002'),
(14, '灰猎犬号', '2020'),
(15, '复仇者联盟4:终局之战', '2019'),
(16, '小森林冬春篇', '2015');
之后,在项目里新建一个db.js
:
const mysql = require('mysql2/promise')
const connection = mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'root',
database: 'movie_db'
})
module.exports = connection
然后在app.js
中修改代码:
const Koa = require('koa')
const Router = require('@koa/router')
const router = new Router()
const bodyParser = require('koa-bodyparser')
const app = new Koa()
const connection = require('./db')
router.get('/', ctx => {
ctx.body = 'server ok'
})
router.get('/movie', async ctx => {
const [rows, fields] = await connection.then(conn => conn.query('SELECT * FROM movie'))
ctx.body = rows
})
app.use(bodyParser())
.use(router.routes())
.listen(3000, () => console.log('server is running at 3000'))
这里要说明的是,mysql2
中实现了Promise wrapper,在库层面上支持了Promise的调用方式。所以这里在db.js
里导出的只是一个Promise,所以在这里是不能直接connection.query()
这种同步调用方式的,只能用.then(conn => {})
这种形式调用。query()
结束后得到的结果里有两个对象,第一个是我们要找的数据,而第二个暂时还不知道是什么,所以这里直接返回res[0]
以此类推,我们可以写出其他接口:
router.post('/movie', async ctx => {
const body = ctx.request.body
const [rows, fields] = await connection.then(conn => conn.query('INSERT INTO movie (name, year) VALUES (?, ?)'
, [body.name, body.year]))
.catch(err => console.log(err))
ctx.body = rows
})
router.put('/movie/:id', async ctx => {
const id = ctx.params.id
const {name, year} = ctx.request.body
const [rows, fields] = await connection.then(conn => conn.query('UPDATE movie SET name=?, year=? WHERE id=?'
, [name, year, id]))
.catch(err => console.log(err))
ctx.body = rows
})
router.del('/movie/:id', async ctx => {
const id = ctx.params.id
const [rows, fields] = await connection.then(conn => conn.query('DELETE FROM movie WHERE id=?', [id]))
ctx.body = rows
})
这样,我们就得到了一个具有基本增删改查功能的Koa应用,下一步我们会对这个应用的代码进行逻辑上的移动和划分,使其结构更为清晰。
改造路由
代码step2
这个应用虽然不是不能用,但是想进一步的话就会发现,路由作为接受http请求的一层,除了要接收http请求还要去处理数据库的操作,所以我们要把它拆开,让路由就做转发请求的事情,剩下的让控制器来处理。
网上已有的一些文章会把路由做成下面这样的配置方式:
[{
method: 'get',
path: '/a',
controller: test.list
},
{
method: 'post',
path: '/signup',
controller: test.signup
}]
在路由里动态地添加这种信息,但我更喜欢eggjs那种router.get('/get', controller.user.get)
这种更直观的形式,所以文章尝试把路由器里的信息改成eggjs的形式。
先新建controller.js
,增加一下代码:
const connection = require('./db')
const serverOk = async ctx => ctx.body = 'server OK'
const getAllMovie = async (ctx) => {
const [row, field] = await connection.then(conn => conn.query('SELECT * FROM movie'))
.catch(err => console.log(err))
ctx.body = row
}
const getMovieById = async (ctx) => {
const id = ctx.params.id
const [row, field] = await connection.then(conn => conn.query('SELECT * FROM movie WHERE id=?', [id]))
.catch(err => console.log(err))
ctx.body = row
}
const createMovie = async (ctx) => {
const body = ctx.request.body
const [row, field] = await connection.then(conn => conn.query('INSERT INTO movie (name, year) VALUES (?, ?)'
, [body.name, body.year]))
.catch(err => console.log(err))
ctx.body = row
}
const changeMovieInfo = async (ctx) => {
const id = ctx.params.id
const {name, year} = ctx.request.body
const [row, field] = await connection.then(conn => conn.query('UPDATE movie SET name=?, year=? WHERE id=?'
, [name, year, id]))
.catch(err => console.log(err))
ctx.body = row
}
const deleteMovie = async (ctx) => {
const id = ctx.params.id
const [row, field] = await connection.then(conn => conn.query('DELETE FROM movie WHERE id=?', [id]))
ctx.body = row
}
module.exports = {
serverOk,
getAllMovie,
getMovieById,
createMovie,
changeMovieInfo,
deleteMovie
}
可以看出来,这里是把原先路由器的请求响应函数单独摘出来了,所以把app.js
中的内容改一下:
const { serverOk, getAllMovie, getMovieById, createMovie, changeMovieInfo, deleteMovie } = require('./controller')
const Router = require("@koa/router")
const router = new Router()
router.get('/', serverOk)
router.get('/movie', getAllMovie)
router.get('/movie/:id', getMovieById)
router.post('/movie', createMovie)
router.put('/movie/:id', changeMovieInfo)
router.del('/movie/:id', deleteMovie)
module.exports = router
这样我们就能在不改动路由响应函数的情况下在controller中修改响应的具体实现,这么改动就把路由器解放出来只接收请求,然后把具体请求转发给相应的函数去处理就好,路由里不用混杂具体的业务代码了。
既然把路由器的功能摊薄了,何不再进一步也把app.js
只用作注册以及启动服务器的作用,新增一个routes.js
文件,把上面那一部分放在这个文件里,在app.js
中引入这个routes.js
。这一步完成后,app.js
里的代码应该为:
const Koa = require('koa')
const bodyParser = require('koa-bodyparser')
const app = new Koa()
const connection = require('./db')
const router = require('./routes')
app.use(bodyParser())
.use(router.routes())
.listen(3000, () => console.log('server is running at 3000'))
routes.js
中的代码应该为:
const { serverOk, getAllMovie, getMovieById, createMovie, changeMovieInfo, deleteMovie } = require('./controller')
const Router = require("@koa/router");
const router = new Router()
router.get('/', serverOk)
router.get('/movie', getAllMovie)
router.get('/movie/:id', getMovieById)
router.post('/movie', createMovie)
router.put('/movie/:id', changeMovieInfo)
router.del('/movie/:id', deleteMovie)
module.exports = router
改造完之后,可以随意地测试一下原先的功能是否还能正常工作。这么着一来,我们就有了app.js
、controller.js
、routes.js
和db.js
,四个文件各有各的职责所在,统一汇总在app.js
中组成了一个虽简单但是完整的应用。在下一步,我们会对这一个应用进行测试,减少我们每次修改代码之后的重复劳动。
测试
代码step3
上一篇文章我们实现了一个最基本的服务用于处理请求,这一篇文章我们进一步,对这个应用进行一些测试来让心里更踏实,关于测试我写过一篇笔记,不过是从前端组件的角度写的,但里面的思想是一致的——把要测试的单元当成一个黑盒,给一个输入,判断输出是否和预期相符。但后端和前端不太一样,一旦牵扯到数据库,和数据库结合的集成测试显然更符合我们对测试的需要。在这个例子里我们结合jest
和supertest
对应用进行集成测试。
先安装两个包npm i jest supertest -D
,把package.json
中关于测试的命令改成
"scripts": {
"test": "jest --forceExit"
},
然后把上一步的代码复制一份到这一步中。supertest的文档中提到,可以向supertest传一个没有进行端口监听的app实例进行测试,那这里我们又要对app.js
进行改造,在这里,我们把监听端口的部分移到新文件server.js
中,这时app.js
的内容如下:
const Koa = require('koa')
const bodyParser = require('koa-bodyparser')
const app = new Koa()
const connection = require('./db')
const router = require('./routes')
app.use(bodyParser())
.use(router.routes())
module.exports = app
server.js
的内容如下:
const app = require('./app')
app.listen(3000, () => console.log('server is running at 3000'))
好了,这时候我们就把应用的定义和实际启动分开了,这时我们就能对应用进行测试了。
新建一个app.spec.js
,添加如下代码:
describe('test app', function () {
it('should pass a sanity check', function () {
expect(true).toBe(true)
});
})
在控制台里输入npm t
,过一会显示测试已通过,说明测试环境已经配置好了。接下来就可以对具体的代码行为进行测试了,新建describe
块,新增如下测试:
describe('GET /', function () {
let res = {}
beforeAll(async () => {
res = await request(app.callback()).get('/')
return res
})
afterAll(() => {
res = {}
return res
})
it('should return 200 status', async function () {
expect(res.status).toBe(200)
})
it('should return test Content-Type', function () {
expect(res.type).toBe('text/plain')
});
it('should return server OK', function () {
expect(res.text).toBe('server OK')
});
});
这段代码先在测试进行前用supertest拿了一个接口,把返回值作为每一个测试的依赖,然后在每个测试中对这个返回值进行断言。可以看出,这里对返回的状态码、Content-Type和返回内容都进行了断言,再次运行npm t
,可以发现测试通过了。同理可以写出其他接口的测试,这里就不再贴代码了。唯一要说明的是,除了GET方法拿到结果可以直接对结果进行测试之外,其他方法的返回值都不会是改动后的结果,那要怎么对结果进行测试呢,以POST修改信息这一个接口为例,在postman进行测试后拿到的返回值如下:
{
"fieldCount": 0,
"affectedRows": 1,
"insertId": 20,
"info": "",
"serverStatus": 2,
"warningStatus": 0
}
就可以根据这个字段断言有这一个字段,测试如下:
it('should return an `insertId`', function () {
expect(res.body).toHaveProperty('insertId')
});
因为这个应用中没有类似movie/:id
这样单独请求某个信息的接口,就不能再把这个insertId
带上查看返回值和输入值是否一致了。