今天写后端的时候遇到一个问题,就是怎么在 Traivs CI 上搞 ORM 单元测试。搜了一圈,发现中文说这个的博客很少,那就在这里分享一下我的经历吧。
背景
这里先说说背景:使用 Express 搭建一个 login 的服务,后端会查询数据库,然后返回相应的结果。
我要做的有两件事:
- 本地单元测试 login 功能
- 部署到 Travis CI 上
再说下我所用到的技术:
- Express.js 后端框架
- sequelize, sequelize-typescript 一个 ORM 框架,后者只是 TypeScript 的支持库
- jest, ts-jest, babel-jest 单元测试框架,后面两个是 TypeScript 和 Babel 的支持工具
- supertest 测试请求的框架
OK,不多废话,我们就开始吧。
login 服务
这是我的 auth route 文件,这里定义了一个 login 的服务。
import express from 'express'
import UserModel from '@/models/UserModel'
// '/auth'
const AuthRouter = express.Router()
// 登录
AuthRouter.post('/login', async (req, res) => {
const {email, password} = req.body
const user = await UserModel.findOne({
where: {email}
})
if (!user) {
res.status(401)
return res.json({
message: '用户不存在'
})
}
if (user.password !== password) {
res.status(401)
return res.json({
message: '密码不正确'
})
}
return res.json(Mock.mock({
token: 'OK'
}))
})
export default AuthRouter
这里的代码很好理解:从 req.body
中拿 email
和 password
,使用 UserModel 去取对应的 user,处理用户不存在、密码不正确两个错误。如果没错误则返回 token。(这里的 token 应该是 JWT Token,为了演示就用 'OK' 表示)
UserModel
这里你可能注意到了 UserModel,那我们当然要定义一下这个 model 了。
import {DataTypes} from 'sequelize'
import {Column, Table, Model} from 'sequelize-typescript'
@Table({tableName: 'users'})
class UserModel extends Model<UserModel> {
@Column(DataTypes.STRING)
public password!: string
@Unique
@Column(DataTypes.STRING)
public email!: string
}
export default UserModel
没看过 sequelize 也没关系,这里就是定义了一张 users 表,字段有 password
和 email
。通过 findOne
就可以做 query 查到对应的 user 了。
sequelize 实例
定义完了 model,就要创建数据库连接了,这里 sequelize 提供的方法很简单,也很容易理解。
import {Sequelize} from 'sequelize-typescript'
import consola from 'consola'
import UserModel from '@/models/UserModel'
import {parseEnv} from '@/utils/config'
// 创建连接实例
const initDB = () => {
const {DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD} = process.env
if (!DB_HOST || !DB_PORT || !DB_NAME || !DB_USER) {
throw new Error('环境变量不存在')
}
const sequelize = new Sequelize({
database: DB_NAME,
dialect: 'mysql',
host: DB_HOST,
port: parseInt(DB_PORT),
username: DB_USER,
password: DB_PASSWORD,
storage: ':memory:',
models: [UserModel],
logging: msg => console.log(msg),
define: {
charset: 'utf8'
}
})
// Test connection
sequelize.authenticate()
.then(() => consola.success('成功连接数据库'))
.catch((error) => consola.error('无法连接数据库: ', error))
return sequelize
}
// 开始读入 Model
parseEnv()
const db = initDB()
export default db
这里要画一下重点:
1. 不去检查 DB_PASSWORD,在 if (!DB_HOST || !DB_PORT || !DB_NAME || !DB_USER)
这里没有对 DB_PASSWORD 做检测,这是因为,我们数据库有可能不设置密码,那么 DB_PASSWORD 应该是等于 undefined
的,所以不需要检测。
2. define: {charset: 'utf8'}
这是一定要定义 utf8,因为有些环境如 Travis CI 中,MySQL 可能不是 utf8,会报错Incorrect string value: '\xF0\x90\x8D\x83\xF0\x90...' for xxx 'content' at row 1
,有乱码的问题。
3. DB_PORT
得到的是 string 类型,要用 parseInt
转成 number 类型。
4. 这里使用了 parseEnv
函数,功能是解析本地下的 .env
文件,当然在像 Travis CI 或者 Heroku 上你是可以在系统中指定 env 变量,但是本地的时候还是需要去解析 .env 文件的。
.env
这里给出 .env 文件。
DB_HOST=127.0.0.1
DB_NAME=test
DB_PORT=3306
DB_USER=root
DB_PASSWORD=45678
parseEnv 函数如下。
import path from 'path'
import dotenv from 'dotenv'
// 解析 .env
export const parseEnv = () => {
const {NODE_ENV} = process.env
if (NODE_ENV !== 'development' && NODE_ENV !== 'dev-test') return
const envPath = path.resolve(__dirname, '../.env')
const result = dotenv.config({
path: envPath
})
if (result.error) {
throw result.error
}
}
这里要说明一下,只有在 development
和 dev-test
环境下才需要去解析。因为 production
环境可以在控制台直接指定 env 变量,如
![](https://img.haomeiwen.com/i2979799/2754cfca2793fcc8.png)
而持续集成环境中也可以在控制台直接指定 env 变量,如
![](https://img.haomeiwen.com/i2979799/91a23b3d7b574b80.png)
所以,在 package.json
中,不仅要添加 test
还要添加 dev-test
,后者用于本地跑测试。
"scripts": {
"test": "jest",
"dev-test": "NODE_ENV=dev-test jest"
}
配置 Jest
上面说的都是我们的业务代码,这里开始说测试,首先,我们装完了 jest
, ts-jest
和 babel-jest
后,要定义 jest.config.js
文件。
module.exports = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1'
},
moduleFileExtensions: [
'ts',
'js',
'json'
],
transform: {
"^.+\\.ts$": "ts-jest",
'^.+\\.js$': 'babel-jest',
},
collectCoverage: true,
collectCoverageFrom: [
'<rootDir>/routes/**/*.ts',
"!**/node_modules/**"
],
coverageReporters: ["html", "text-summary", "lcov"],
}
只需要关注 collectCoverageFrom
,我们要测试 /routes 下的文件,不测试 node_modules 下的文件。
测试用例
下面我们来写一个简单的测试用例吧。
import request from 'supertest'
import db from '@/models/db'
import app from '@/app'
import {initMockDB} from '../../../mocks/dbObjects'
describe('auth', () => {
beforeAll(async () => {
await db.sync({force: true})
await initMockDB()
})
afterAll(async () => {
await db.close()
})
describe('/login', () => {
it('成功登录', async () => {
const res: request.Response = await request(app)
.post('/api/auth/login')
.send({
email: 'user1@mail.com',
password: '123456'
})
expect(res.status).toEqual(200)
expect(res.body).toHaveProperty('token')
})
})
})
这里的 db
指的就是 sequelize 创建出来的实例,你也可以理解成数据库连接。
beforeAll
这里做的是强制清除原来的数据库,再按 UserModel 去创建 users 表。initMockDB
就是用 sequelize api 去创建一个用户,这里理解一下就好了,很简单,不放代码了。
afterAll
这里做的是要关闭这个数据库连接。否则会出现报错:test case 还没完全跑完,jest 会一直在等待。
describe
里应该容易理解,就是发个 login 请求,判断 status 和 res.body 是否存在 token。
运行 yarn dev-test
应该会显示成功执行测试。
注意:这里有个小坑,如果遇到了下面找不到测试用例的情况:
![](https://img.haomeiwen.com/i2979799/557231bfe66e655f.png)
可以尝试跑
yarn dev-test --no-cache
,这是因为在生成 coverage
report 的时候会出现 map 不成功的问题。可以看这里了解更多。
Travis CI
搞完了本地,当然要搞 Travis CI 了,不为什么,就为了可以在 Readme 上放个既帅气又装逼的 build|passing
小标。这里先给个 .travis.yml
文件。
language: node_js
node_js:
- node
cache:
yarn: true
services:
- mysql
before_script:
- mysql -e 'create database test;'
services
里注明要安装一个 MySQL,然后 before_scripts
里我们创建一个 test 数据库,注意,这个数据库是没有密码的(官网说了)。
除了要配置 .travis.yml
文件,我们还要在 Travis CI 网站上配置 env 变量。
![](https://img.haomeiwen.com/i2979799/64d4209efa26e63f.png)
因为 MySQL 数据库是没有密码的,所以就不需要配置 DB_PASSWORD
了。
最后一步就是 git commit -m '终于写了一个单元测试'
然后 git push
就 OK 了。
完
网友评论