Supertest 教程

1. 核心知识点讲解

1.1 Supertest 简介

Supertest 是一个功能强大的 HTTP 测试库,专门用于测试 Node.js HTTP 服务器。它建立在 Superagent 之上,提供了简洁、直观的 API 来测试 HTTP 请求和响应,支持各种 HTTP 方法和断言。

1.2 安装和配置

安装 Supertest 和测试框架:

npm install --save-dev supertest mocha chai

基本使用:

const request = require('supertest');
const app = require('../app'); // Express/Koa 应用

describe('API 测试', function() {
  it('应该返回 200 OK', function(done) {
    request(app)
      .get('/')
      .expect(200, done);
  });
});

1.3 基本测试结构

测试 HTTP GET 请求:

const request = require('supertest');
const app = require('../app');

describe('GET /users', function() {
  it('应该返回用户列表', function(done) {
    request(app)
      .get('/users')
      .expect('Content-Type', /json/)
      .expect(200)
      .end(function(err, res) {
        if (err) return done(err);
        // 额外的断言
        expect(res.body).to.be.an('array');
        done();
      });
  });
});

测试 HTTP POST 请求:

const request = require('supertest');
const app = require('../app');

describe('POST /users', function() {
  it('应该创建新用户', function(done) {
    request(app)
      .post('/users')
      .send({ name: '张三', email: 'zhangsan@example.com' })
      .expect('Content-Type', /json/)
      .expect(201)
      .end(function(err, res) {
        if (err) return done(err);
        expect(res.body).to.have.property('id');
        expect(res.body.name).to.equal('张三');
        done();
      });
  });
});

1.4 支持的 HTTP 方法

Supertest 支持所有 HTTP 方法:

  • get(path) - GET 请求
  • post(path) - POST 请求
  • put(path) - PUT 请求
  • patch(path) - PATCH 请求
  • delete(path) - DELETE 请求
  • head(path) - HEAD 请求
  • options(path) - OPTIONS 请求

示例:

// PUT 请求
request(app)
  .put('/users/1')
  .send({ name: '李四' })
  .expect(200)
  .end(done);

// DELETE 请求
request(app)
  .delete('/users/1')
  .expect(204)
  .end(done);

1.5 请求配置

设置请求头:

request(app)
  .get('/protected')
  .set('Authorization', 'Bearer token123')
  .set('Content-Type', 'application/json')
  .expect(200)
  .end(done);

设置查询参数:

request(app)
  .get('/users')
  .query({ page: 1, limit: 10 })
  .expect(200)
  .end(done);

设置请求体:

// JSON 格式
request(app)
  .post('/users')
  .send({ name: '张三', email: 'zhangsan@example.com' })
  .expect(201)
  .end(done);

// 表单格式
request(app)
  .post('/login')
  .type('form')
  .send({ username: 'admin', password: 'password' })
  .expect(200)
  .end(done);

1.6 响应断言

状态码断言:

request(app)
  .get('/not-found')
  .expect(404)
  .end(done);

响应头断言:

request(app)
  .get('/')
  .expect('Content-Type', /json/)
  .expect('X-Powered-By', 'Express')
  .end(done);

响应体断言:

request(app)
  .get('/users/1')
  .expect(200)
  .expect(function(res) {
    res.body.id.should.equal(1);
    res.body.name.should.equal('张三');
  })
  .end(done);

1.7 异步测试支持

使用 async/await:

const request = require('supertest');
const app = require('../app');
const { expect } = require('chai');

describe('异步 API 测试', function() {
  it('应该返回用户列表', async function() {
    const res = await request(app)
      .get('/users')
      .expect('Content-Type', /json/)
      .expect(200);
    
    expect(res.body).to.be.an('array');
  });

  it('应该创建新用户', async function() {
    const res = await request(app)
      .post('/users')
      .send({ name: '张三', email: 'zhangsan@example.com' })
      .expect('Content-Type', /json/)
      .expect(201);
    
    expect(res.body).to.have.property('id');
    expect(res.body.name).to.equal('张三');
  });
});

1.8 测试文件上传

测试文件上传:

const request = require('supertest');
const app = require('../app');
const path = require('path');

describe('文件上传测试', function() {
  it('应该上传文件', function(done) {
    request(app)
      .post('/upload')
      .attach('file', path.resolve(__dirname, '../test-file.txt'))
      .expect(200)
      .end(function(err, res) {
        if (err) return done(err);
        expect(res.body).to.have.property('filename');
        done();
      });
  });
});

2. 实用案例分析

2.1 基本 API 测试案例

场景: 测试一个简单的 Express 应用的用户 API

实现:

// app.js
const express = require('express');
const app = express();

app.use(express.json());

let users = [
  { id: 1, name: '张三', email: 'zhangsan@example.com' },
  { id: 2, name: '李四', email: 'lisi@example.com' }
];

// 获取用户列表
app.get('/users', (req, res) => {
  res.json(users);
});

// 获取单个用户
app.get('/users/:id', (req, res) => {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) return res.status(404).json({ error: '用户不存在' });
  res.json(user);
});

// 创建用户
app.post('/users', (req, res) => {
  const newUser = {
    id: users.length + 1,
    name: req.body.name,
    email: req.body.email
  };
  users.push(newUser);
  res.status(201).json(newUser);
});

// 更新用户
app.put('/users/:id', (req, res) => {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) return res.status(404).json({ error: '用户不存在' });
  
  user.name = req.body.name || user.name;
  user.email = req.body.email || user.email;
  
  res.json(user);
});

// 删除用户
app.delete('/users/:id', (req, res) => {
  const userIndex = users.findIndex(u => u.id === parseInt(req.params.id));
  if (userIndex === -1) return res.status(404).json({ error: '用户不存在' });
  
  users.splice(userIndex, 1);
  res.status(204).send();
});

module.exports = app;

// test/api.test.js
const request = require('supertest');
const app = require('../app');
const { expect } = require('chai');

describe('用户 API 测试', function() {
  describe('GET /users', function() {
    it('应该返回用户列表', async function() {
      const res = await request(app)
        .get('/users')
        .expect('Content-Type', /json/)
        .expect(200);
      
      expect(res.body).to.be.an('array');
      expect(res.body.length).to.be.at.least(2);
    });
  });

  describe('GET /users/:id', function() {
    it('应该返回指定用户', async function() {
      const res = await request(app)
        .get('/users/1')
        .expect('Content-Type', /json/)
        .expect(200);
      
      expect(res.body).to.have.property('id', 1);
      expect(res.body).to.have.property('name', '张三');
    });

    it('应该返回 404 当用户不存在', async function() {
      const res = await request(app)
        .get('/users/999')
        .expect('Content-Type', /json/)
        .expect(404);
      
      expect(res.body).to.have.property('error', '用户不存在');
    });
  });

  describe('POST /users', function() {
    it('应该创建新用户', async function() {
      const res = await request(app)
        .post('/users')
        .send({ name: '王五', email: 'wangwu@example.com' })
        .expect('Content-Type', /json/)
        .expect(201);
      
      expect(res.body).to.have.property('id');
      expect(res.body).to.have.property('name', '王五');
      expect(res.body).to.have.property('email', 'wangwu@example.com');
    });
  });

  describe('PUT /users/:id', function() {
    it('应该更新指定用户', async function() {
      const res = await request(app)
        .put('/users/1')
        .send({ name: '张三更新', email: 'zhangsan-updated@example.com' })
        .expect('Content-Type', /json/)
        .expect(200);
      
      expect(res.body).to.have.property('id', 1);
      expect(res.body).to.have.property('name', '张三更新');
      expect(res.body).to.have.property('email', 'zhangsan-updated@example.com');
    });

    it('应该返回 404 当用户不存在', async function() {
      const res = await request(app)
        .put('/users/999')
        .send({ name: '测试' })
        .expect('Content-Type', /json/)
        .expect(404);
      
      expect(res.body).to.have.property('error', '用户不存在');
    });
  });

  describe('DELETE /users/:id', function() {
    it('应该删除指定用户', async function() {
      await request(app)
        .delete('/users/2')
        .expect(204);
      
      // 验证用户已删除
      const res = await request(app)
        .get('/users/2')
        .expect(404);
      
      expect(res.body).to.have.property('error', '用户不存在');
    });

    it('应该返回 404 当用户不存在', async function() {
      const res = await request(app)
        .delete('/users/999')
        .expect('Content-Type', /json/)
        .expect(404);
      
      expect(res.body).to.have.property('error', '用户不存在');
    });
  });
});

2.2 认证 API 测试案例

场景: 测试一个需要认证的 API

实现:

// app.js
const express = require('express');
const app = express();

app.use(express.json());

// 模拟认证中间件
function authenticate(req, res, next) {
  const token = req.headers.authorization;
  if (!token || !token.startsWith('Bearer ')) {
    return res.status(401).json({ error: '未授权' });
  }
  
  const tokenValue = token.split(' ')[1];
  if (tokenValue !== 'valid-token') {
    return res.status(401).json({ error: '无效的令牌' });
  }
  
  req.user = { id: 1, name: '管理员' };
  next();
}

// 公开路由
app.get('/public', (req, res) => {
  res.json({ message: '公开路由' });
});

// 需要认证的路由
app.get('/protected', authenticate, (req, res) => {
  res.json({ message: '受保护的路由', user: req.user });
});

module.exports = app;

// test/auth.test.js
const request = require('supertest');
const app = require('../app');
const { expect } = require('chai');

describe('认证 API 测试', function() {
  describe('GET /public', function() {
    it('应该可以访问公开路由', async function() {
      const res = await request(app)
        .get('/public')
        .expect('Content-Type', /json/)
        .expect(200);
      
      expect(res.body).to.have.property('message', '公开路由');
    });
  });

  describe('GET /protected', function() {
    it('应该返回 401 当没有提供令牌', async function() {
      const res = await request(app)
        .get('/protected')
        .expect('Content-Type', /json/)
        .expect(401);
      
      expect(res.body).to.have.property('error', '未授权');
    });

    it('应该返回 401 当提供无效令牌', async function() {
      const res = await request(app)
        .get('/protected')
        .set('Authorization', 'Bearer invalid-token')
        .expect('Content-Type', /json/)
        .expect(401);
      
      expect(res.body).to.have.property('error', '无效的令牌');
    });

    it('应该可以访问受保护路由当提供有效令牌', async function() {
      const res = await request(app)
        .get('/protected')
        .set('Authorization', 'Bearer valid-token')
        .expect('Content-Type', /json/)
        .expect(200);
      
      expect(res.body).to.have.property('message', '受保护的路由');
      expect(res.body).to.have.property('user');
      expect(res.body.user).to.have.property('id', 1);
      expect(res.body.user).to.have.property('name', '管理员');
    });
  });
});

2.3 错误处理测试案例

场景: 测试 API 的错误处理

实现:

// app.js
const express = require('express');
const app = express();

app.use(express.json());

// 模拟会抛出错误的路由
app.get('/error', (req, res) => {
  throw new Error('服务器内部错误');
});

// 404 处理
app.use((req, res) => {
  res.status(404).json({ error: '路由不存在' });
});

// 错误处理中间件
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: '服务器内部错误' });
});

module.exports = app;

// test/error.test.js
const request = require('supertest');
const app = require('../app');
const { expect } = require('chai');

describe('错误处理测试', function() {
  describe('GET /error', function() {
    it('应该返回 500 当服务器内部错误', async function() {
      const res = await request(app)
        .get('/error')
        .expect('Content-Type', /json/)
        .expect(500);
      
      expect(res.body).to.have.property('error', '服务器内部错误');
    });
  });

  describe('GET /non-existent', function() {
    it('应该返回 404 当路由不存在', async function() {
      const res = await request(app)
        .get('/non-existent')
        .expect('Content-Type', /json/)
        .expect(404);
      
      expect(res.body).to.have.property('error', '路由不存在');
    });
  });

  describe('POST /users', function() {
    it('应该返回 404 当路由不存在', async function() {
      const res = await request(app)
        .post('/users')
        .send({ name: '测试' })
        .expect('Content-Type', /json/)
        .expect(404);
      
      expect(res.body).to.have.property('error', '路由不存在');
    });
  });
});

3. 代码示例

3.1 基本测试示例

测试 Express 应用:

const request = require('supertest');
const express = require('express');
const app = express();

app.get('/', function(req, res) {
  res.status(200).json({ message: 'Hello, World!' });
});

app.get('/users', function(req, res) {
  res.status(200).json([
    { id: 1, name: '张三' },
    { id: 2, name: '李四' }
  ]);
});

app.post('/users', function(req, res) {
  res.status(201).json({ id: 3, name: req.body.name });
});

describe('Express 应用测试', function() {
  it('GET / 应该返回 200 OK', function(done) {
    request(app)
      .get('/')
      .expect('Content-Type', /json/)
      .expect(200)
      .expect({ message: 'Hello, World!' })
      .end(done);
  });

  it('GET /users 应该返回用户列表', function(done) {
    request(app)
      .get('/users')
      .expect('Content-Type', /json/)
      .expect(200)
      .end(function(err, res) {
        if (err) return done(err);
        if (res.body.length !== 2) {
          return done(new Error(`期望 2 个用户,但得到 ${res.body.length} 个`));
        }
        done();
      });
  });

  it('POST /users 应该创建新用户', function(done) {
    request(app)
      .post('/users')
      .send({ name: '王五' })
      .expect('Content-Type', /json/)
      .expect(201)
      .expect({ id: 3, name: '王五' })
      .end(done);
  });
});

3.2 高级测试示例

测试文件上传:

const request = require('supertest');
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();

// 配置 multer
const storage = multer.diskStorage({
  destination: function(req, file, cb) {
    cb(null, 'uploads/');
  },
  filename: function(req, file, cb) {
    cb(null, Date.now() + '-' + file.originalname);
  }
});

const upload = multer({ storage: storage });

// 文件上传路由
app.post('/upload', upload.single('file'), function(req, res) {
  if (!req.file) {
    return res.status(400).json({ error: '没有文件被上传' });
  }
  res.status(200).json({ filename: req.file.filename });
});

// 测试文件上传
const fs = require('fs');
const { expect } = require('chai');

describe('文件上传测试', function() {
  // 创建测试文件
  before(function(done) {
    fs.writeFile('test-file.txt', '测试文件内容', done);
  });

  // 清理测试文件
  after(function(done) {
    fs.unlink('test-file.txt', done);
  });

  it('应该上传文件成功', function(done) {
    request(app)
      .post('/upload')
      .attach('file', path.resolve(__dirname, 'test-file.txt'))
      .expect('Content-Type', /json/)
      .expect(200)
      .end(function(err, res) {
        if (err) return done(err);
        expect(res.body).to.have.property('filename');
        // 清理上传的文件
        fs.unlink(path.resolve(__dirname, 'uploads', res.body.filename), done);
      });
  });

  it('应该返回 400 当没有上传文件', function(done) {
    request(app)
      .post('/upload')
      .expect('Content-Type', /json/)
      .expect(400)
      .end(function(err, res) {
        if (err) return done(err);
        expect(res.body).to.have.property('error', '没有文件被上传');
        done();
      });
  });
});

3.3 与测试框架集成

与 Mocha 和 Chai 集成:

const request = require('supertest');
const express = require('express');
const { expect } = require('chai');
const app = express();

app.use(express.json());

let books = [
  { id: 1, title: 'JavaScript 权威指南', author: 'David Flanagan' },
  { id: 2, title: 'Node.js 实战', author: 'Mike Cantelon' }
];

app.get('/books', function(req, res) {
  res.json(books);
});

app.get('/books/:id', function(req, res) {
  const book = books.find(b => b.id === parseInt(req.params.id));
  if (!book) return res.status(404).json({ error: '书籍不存在' });
  res.json(book);
});

app.post('/books', function(req, res) {
  const newBook = {
    id: books.length + 1,
    title: req.body.title,
    author: req.body.author
  };
  books.push(newBook);
  res.status(201).json(newBook);
});

describe('书籍 API 测试', function() {
  describe('GET /books', function() {
    it('应该返回书籍列表', async function() {
      const res = await request(app)
        .get('/books')
        .expect('Content-Type', /json/)
        .expect(200);
      
      expect(res.body).to.be.an('array');
      expect(res.body.length).to.equal(2);
      expect(res.body[0]).to.have.property('title', 'JavaScript 权威指南');
    });
  });

  describe('GET /books/:id', function() {
    it('应该返回指定书籍', async function() {
      const res = await request(app)
        .get('/books/1')
        .expect('Content-Type', /json/)
        .expect(200);
      
      expect(res.body).to.have.property('id', 1);
      expect(res.body).to.have.property('title', 'JavaScript 权威指南');
    });

    it('应该返回 404 当书籍不存在', async function() {
      const res = await request(app)
        .get('/books/999')
        .expect('Content-Type', /json/)
        .expect(404);
      
      expect(res.body).to.have.property('error', '书籍不存在');
    });
  });

  describe('POST /books', function() {
    it('应该创建新书籍', async function() {
      const res = await request(app)
        .post('/books')
        .send({ title: 'React 实战', author: 'Alex Banks' })
        .expect('Content-Type', /json/)
        .expect(201);
      
      expect(res.body).to.have.property('id', 3);
      expect(res.body).to.have.property('title', 'React 实战');
      expect(res.body).to.have.property('author', 'Alex Banks');
    });
  });
});

3.4 测试 Koa 应用

测试 Koa 应用:

const request = require('supertest');
const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const app = new Koa();

app.use(bodyParser());

// 中间件
app.use(async (ctx) => {
  if (ctx.path === '/' && ctx.method === 'GET') {
    ctx.status = 200;
    ctx.body = { message: 'Hello, Koa!' };
  } else if (ctx.path === '/users' && ctx.method === 'GET') {
    ctx.status = 200;
    ctx.body = [{ id: 1, name: '张三' }];
  } else if (ctx.path === '/users' && ctx.method === 'POST') {
    ctx.status = 201;
    ctx.body = { id: 2, name: ctx.request.body.name };
  } else {
    ctx.status = 404;
    ctx.body = { error: '路由不存在' };
  }
});

// 转换 Koa 应用为 Node.js HTTP 服务器
const server = app.listen();

describe('Koa 应用测试', function() {
  after(function() {
    server.close();
  });

  it('GET / 应该返回 200 OK', function(done) {
    request(server)
      .get('/')
      .expect('Content-Type', /json/)
      .expect(200)
      .expect({ message: 'Hello, Koa!' })
      .end(done);
  });

  it('GET /users 应该返回用户列表', function(done) {
    request(server)
      .get('/users')
      .expect('Content-Type', /json/)
      .expect(200)
      .end(function(err, res) {
        if (err) return done(err);
        if (res.body.length !== 1) {
          return done(new Error(`期望 1 个用户,但得到 ${res.body.length} 个`));
        }
        done();
      });
  });

  it('POST /users 应该创建新用户', function(done) {
    request(server)
      .post('/users')
      .send({ name: '李四' })
      .expect('Content-Type', /json/)
      .expect(201)
      .expect({ id: 2, name: '李四' })
      .end(done);
  });

  it('GET /non-existent 应该返回 404', function(done) {
    request(server)
      .get('/non-existent')
      .expect('Content-Type', /json/)
      .expect(404)
      .expect({ error: '路由不存在' })
      .end(done);
  });
});

4. 总结

Supertest 是一个功能强大的 HTTP 测试库,专门用于测试 Node.js HTTP 服务器。本教程介绍了 Supertest 的核心概念,包括:

  • 安装和配置
  • 基本测试结构
  • HTTP 方法测试
  • 请求配置
  • 响应断言
  • 异步测试支持
  • 文件上传测试
  • 错误处理测试

通过学习这些概念,你可以开始在项目中使用 Supertest 编写 HTTP API 测试,确保 API 的正确性和可靠性。Supertest 的简洁、直观的 API 使得测试编写变得简单而高效,无论是测试 Express、Koa 还是其他 Node.js HTTP 服务器。

5. 进一步学习资源

希望本教程对你学习 Supertest 有所帮助!

« 上一篇 Sinon 教程 下一篇 » Nodemon 教程