教程:FullStackOpen2022/Part 4

软件开发的一个重要环节,就是自动化测试。

本文中以 bloglist 应用为例。对应练习 4.3-4.14。

1. 配置测试环境

通常的做法是为开发和测试定义不同的模式。而 Node 中是用 NODE_ENV 环境变量定义应用的执行模式。

在脚本中指定应用模式的方式不能在 Windows 上工作,这可以通过安装 cross-env 作为一个开发依赖包来修复。另外,如果应用是部署在 Heroku上,cross-env 作为开发依赖项可能在 web 服务器上报错,可以将 cross-env 保存为生产依赖修复这个错误。

npm install --save-dev cross-env
npm i cross-env -P

通过在 package.json 中定义 npm 脚本使用跨平台兼容性的 cross-env 库来配置环境:

"scripts": {
  "start": "cross-env NODE_ENV=production node index.js",
  "dev": "cross-env NODE_ENV=development nodemon index.js",
  "test": "cross-env NODE_ENV=test jest --verbose --runInBand"
},

对定义应用配置的模块 config.js 进行一些修改:

require('dotenv').config()

const PORT = process.env.PORT;

const MONGODB_URI = process.env.NODE_ENV === 'test' 
  ? process.env.TEST_MONGODB_URI
  : process.env.MONGODB_URI

module.exports = {
  MONGODB_URI,
  PORT
}

修改日志记录器 logger.js,使其不会在测试模式下打印到控制台:

const info = (...params) => {
  if (process.env.NODE_ENV !== "test") {
    console.log(...params);
  }
};

const error = (...params) => {
  if (process.env.NODE_ENV !== "test") {
    console.error(...params);
  }
};

module.exports = {
  info,
  error,
};

2. 安装 Jest

Jest 是Facebook 内部开发和使用的测试库,它可以很好地测试后端,并且在测试 React 应用时表现出色。

npm install --save-dev jest

定义 npm script test,用 Jest 执行测试,用 verbose 样式报告测试执行情况,runInBand 选项防止 Jest 并行运行测试:

"scripts": {
  //...
  "test": "jest --verbose --runInBand"
},

Jest 需要指定执行环境为 Node。 可以通过在 package.json 添加配置,或者,Jest 会查找默认名为 jest.config.js 的配置文件,在配置文件里定义执行环境。两种配置方式同时使用时会引发 warning。

package.json:

{
 //...
 "jest": {
   "testEnvironment": "node"
 }
}

jest.config.js:

module.exports = {
  testEnvironment: "node",
};

通过在 .eslintrc.js 文件的 env 属性中添加 "jest": true 来消除不需要的提示。

module.exports = {
  'env': {
    //...
    'jest': true,
  },
  //...
}

为测试创建一个名为 tests 的单独目录,并创建名为 xxxx.test.js 的新测试文件。

npm test 命令执行应用的所有测试,但通常明智的做法是一次只执行一个或两个测试。Jest 中,可以使用 only 方法,或者指定需要运行的测试作为 npm test 命令的参数。

npm test -- tests/blog_api.test.js
npm test -- -t "a specific test"
npm test -- -t 'blog'

3. 使用 Jest 和 supertest 测试 API

配置本地测试用 MongoDB

测试执行时,通常要求并发运行的测试,因此使用单个数据库实例并不合适,最好使用开发人员本地机器上的数据库来运行测试,最佳的解决方案是让每个测试用例执行时使用自己独立的数据库。

可以通过运行内存中的 Mongo 或使用 Docker 容器来实现。

使用 Laragon 运行本地 MongoDB 的方法:

测试辅助模块

将重复的测试步骤提取到辅助函数中,将这些函数添加到一个名为 tests/test_helper.js 的新文件中,该文件与测试文件位于同一目录中。

const Blog = require("../models/blog");

const initialBlogs = [
  {
    _id: "5a422a851b54a676234d17f7",
    title: "React patterns",
    author: "Michael Chan",
    url: "https://reactpatterns.com/",
    likes: 7,
    __v: 0,
  },
  //...
];

const blogsInDb = async () => {
  return await Blog.find({});
};

const nonExistingId = async () => {
  const blog = new Blog({
    title: "A New Blog",
    author: "New Author",
    url: "https://codepen.io/travist/full/jrBjBz/",
    likes: 2,
  });
  await blog.save();
  await blog.remove();

  return blog._id.toString();
};

module.exports = {
  initialBlogs,
  blogsInDb,
  nonExistingId
};

该模块定义了 blogsInDb 函数,该函数可用于检查数据库中存储的 blog。包含初始数据库状态的 initialBlogs 数组也在模块中。 同时提前定义了 nonExistingId 函数,该函数可用于创建不属于数据库中任何 blog 对象的数据库对象 ID。

使用 beforeEach() 和 afterAll() 初始化/关闭数据库

在每个 test 之前使用 beforeEach 函数初始化数据库:

const mongoose = require("mongoose");
const supertest = require("supertest");
const helper = require("./test_helper");
const app = require("../app");
const api = supertest(app);
const Blog = require("../models/blog");
const _ = require("lodash");

beforeEach(async () => {
  await Blog.deleteMany({});
  await Blog.insertMany(helper.initialBlogs);
});

//...

或:

beforeEach(async () => {
  await Blog.deleteMany({});
  const blogs = helper.initialBlogs.map((blog) => new Blog(blog));
  const blogSaves = blogs.map((blog) => blog.save());
  await Promise.all(blogSaves);
}

Promise.all 方法可以用于将一个 promises 数组转换为一个单一的 promise,一旦数组中的每个promise 作为参数被解析传递给它,它就会被实现。最后一行代码 await Promise.all(blogSaves) 会等待着每个保存 blog 的 promise 都完成,这意味着数据库已经初始化。

Promise.all 并行执行它所收到的 promises。如果 promise 需要按照特定顺序执行,操作可以转而在一个 for... of 块中执行,保证一个特定的执行顺序。

一旦所有的测试已经完成运行,必须使用 Mongoose 关闭数据库连接。这可以通过 afterAll 方法来实现。

//...
afterAll(() => {
  mongoose.connection.close();
});

编写测试

完整测试如下:

const mongoose = require("mongoose");
const supertest = require("supertest");
const helper = require("./test_helper");
const app = require("../app");
const api = supertest(app);
const Blog = require("../models/blog");
const _ = require("lodash");

beforeEach(async () => {
  await Blog.deleteMany({});
  await Blog.insertMany(helper.initialBlogs);
});

describe("get all blogs", () => {
  test("blogs are returned as json", async () => {
    await api
      .get("/api/blogs")
      .expect(200)
      .expect("Content-Type", /application\/json/);
  }, 5000);

  test("number of returned blogs are correct", async () => {
    const response = await api.get("/api/blogs");
    expect(response.body).toHaveLength(helper.initialBlogs.length);
  });

  test("a specific blog is within the returned blogs", async () => {
    const response = await api.get("/api/blogs");
    expect(response.body.map((r) => r.title)).toContain(
      helper.initialBlogs[_.random(helper.initialBlogs.length - 1)].title
    );
  });
});

describe("get one blog", () => {
  test("succeeds with a valid id", async () => {
    const allBlogs = await helper.blogsInDb();
    const blog = allBlogs[_.random(helper.initialBlogs.length - 1)];

    const result = await api
      .get(`/api/blogs/${blog.id}`)
      .expect(200)
      .expect("Content-Type", /application\/json/);

    expect(result.body).toEqual(JSON.parse(JSON.stringify(blog)));
  });

  test("fails with statuscode 404 if note does not exist", async () => {
    const id = await helper.nonExistingId();
    // console.log(id);
    await api.get(`/api/blogs/${id}`).expect(404);
  });

  test("fails with statuscode 400 id is invalid", async () => {
    const invalidId = "5a3d5da59070081a82a3445";
    await api.get(`/api/blogs/${invalidId}`).expect(400);
  });
});

describe("add a new blog", () => {
  test("a valid blog can be added", async () => {
    const newBlog = {
      title: "A New Blog",
      author: "New Author",
      url: "https://codepen.io/travist/full/jrBjBz/",
      likes: 2,
    };

    const response = await api
      .post("/api/blogs")
      .send(newBlog)
      .expect(201)
      .expect("Content-Type", /application\/json/);

    expect(response.body.title).toBe(newBlog.title);
    expect(response.body.author).toBe(newBlog.author);
    expect(response.body.url).toBe(newBlog.url);
    expect(response.body.likes).toBe(newBlog.likes);

    const allBlogs = await helper.blogsInDb();
    const contents = allBlogs.map((r) => r.url);

    expect(allBlogs).toHaveLength(helper.initialBlogs.length + 1);
    expect(contents).toContain("https://codepen.io/travist/full/jrBjBz/");
  });

  test("a blog without likes is added with likes=0", async () => {
    const newBlog = {
      title: "A New Blog",
      author: "New Author",
      url: "https://codepen.io/travist/full/jrBjBz/",
    };

    const response = await api
      .post("/api/blogs")
      .send(newBlog)
      .expect(201)
      .expect("Content-Type", /application\/json/);

    expect(response.body.likes).toBe(0);

    const allBlogs = await helper.blogsInDb();
    expect(allBlogs).toHaveLength(helper.initialBlogs.length + 1);
  });

  test("a blog without title or url cannot be added", async () => {
    const newBlog = {
      author: "New Author",
      likes: 2,
    };

    await api.post("/api/blogs").send(newBlog).expect(400);

    const allBlogs = await helper.blogsInDb();
    expect(allBlogs).toHaveLength(helper.initialBlogs.length);
  });
});

describe("delete a blog", () => {
  test("succeeds with status code 204 if id is valid", async () => {
    const allBlogs = await helper.blogsInDb();
    const blog = allBlogs[_.random(helper.initialBlogs.length - 1)];

    await api.delete(`/api/blogs/${blog.id}`).expect(204);

    const allBlogsAfterDelete = await helper.blogsInDb();
    expect(allBlogsAfterDelete).toHaveLength(helper.initialBlogs.length - 1);

    const contents = allBlogsAfterDelete.map((r) => r.title);
    expect(contents).not.toContain(blog.title);
  });

  test("fails with statuscode 204 if note does not exist", async () => {
    const id = await helper.nonExistingId();
    await api.delete(`/api/blogs/${id}`).expect(204);
  });

  test("fails with statuscode 400 id is invalid", async () => {
    const invalidId = "5a3d5da59070081a82a3445";
    await api.delete(`/api/blogs/${invalidId}`).expect(400);
  });
});

afterAll(() => {
  mongoose.connection.close();
});

4. HTTP status code

测试中涉及的 HTTP status code:

  • 200 OK:请求成功。
  • 201 Created:该请求已成功,并因此创建了一个新的资源。这通常是在POST请求,或是某些PUT请求之后返回的响应。
  • 204 No Content:对于该请求没有的内容可发送,但头部字段可能有用。
  • 400 Bad Request:由于客户端错误,服务器无法或不会处理请求。
  • 404 Not Found:服务器找不到请求的资源。在浏览器中,这意味着无法识别URL。在API中,这也可能意味着端点有效,但资源本身不存在。

5. async/await

async/await 语法在 ES7 引入 JS,其目的是使用异步调用函数来返回一个 promise,但使代码看起来像是同步调用。

为了使用 await 操作符来执行异步操作,它的返回必须是一个 promise。await 关键字不能随意使用,只能在async函数中使用,必须使用 async 来声明整个函数。

使用 async/await 处理异常的推荐方法是 try/catch 机制。使用 express-async-errors 库可以消除 try/catch 块,如果在 async 路由中发生异常,执行将自动传递到错误处理中间件。