如何测试与API或数据库耦合的代码

231 阅读16分钟

在我们对测试驱动开发的浅显介绍中,我们学到了TDD的基本知识。你现在知道了,你应该从一个失败的测试开始,写最少的代码来使它通过,并重构它使之更干净。红-绿-重构。

直截了当,对吗?虽然可能需要一段时间来内化并开始这样工作,但物理方面--它的机制--是很容易掌握的。这是个好消息。

不好的消息是,当你走到现实世界时,你会遇到需要测试依赖于各种基础设施的代码的情况:外部API、服务、数据库、缓存、Webhooks等等。

对于刚接触TDD的开发者来说,在这一点上放弃测试驱动的旅程是非常普遍的。几年前我刚开始接触TDD时,它似乎并不实用。我只能通过简单的事情,如简单的函数或纯粹的React组件,弄清楚如何进行TDD的方式。

那么问题来了:我们如何测试那些依赖于基础设施的代码?难道我们每次想写单元测试的时候,都要启动数据库,把它拆掉,再把它播种?每次我们想测试依赖于付费外部API的代码时,我们都要花真正的钱吗?(提示:答案是不,你不需要)。

在这篇文章中,我们将讨论如何测试依赖基础设施的代码。而我们要做的是,首先 将核心代码与基础设施代码分开

核心代码和基础设施代码

我说的核心代码基础设施代码是什么意思?以及为什么要把它们分开?

核心代码

核心代码是你的应用程序的核心。这是你的家族珠宝。它是使你的应用程序特别的东西。

这段代码也是代表基本复杂性的代码。它封装了领域和应用的现实世界的复杂性。

考虑一个createUser 用例。你需要。

    1. 检查用户的详细信息,如他们的电子邮件和密码是否有效
    1. 检查该账户是否已经创建,以及
    1. 确认该用户的账号是否已经被占用。

因为这些规则代表了应用程序的性质,以及使用例成功或失败的原因,我们称之为应用逻辑。应用逻辑通常依赖于基础设施,所以根据我们的编写方式,它可能是也可能不是完全纯粹的、与基础设施解耦的。

另一方面,如果我们想得更深一层,我们就可以看到领域逻辑。领域逻辑更多的是关于核心业务对象的数据和行为(想想User 实体Email 值对象)。正因为如此,我们可以通过回想状态机和画出合法的行为而受益。

例如,让我们考虑一台洗衣机。洗衣机的状态机将说明这样一个事实:你可以从OFFON ,然后再到WASH ,但它会限制你从OFFWASHON 。这将使洗衣机进入一个非法状态。域逻辑是关于执行这些规则的--通常是验证和对象行为。

Washing machine

领域逻辑通常是纯粹的。它通常不包含对基础设施的依赖性,而且是由你自己编写的。这使得它很容易进行单元测试。

所有这些东西都需要被测试。作为一个测试驱动的开发者,你的目标是把你的应用程序和领域逻辑的基本复杂性变成测试代码--使用你知道的最简单、最灵活和可维护的方式。

基础设施代码

在现实世界中,实现网络应用的方式是将其与基础设施集成。如果我们不能在云端的某个地方托管它,如果它不能以某种方式维护状态,或者如果我们不能向后端API发出请求,那么应用和领域逻辑就不能做什么。

在后端,基础设施代码是指像你的数据库,你的缓存,你的本地文件系统,或你的REST或GraphQL网络服务器这样的关注。在前端,这是你的视图层库,你的GraphQL或REST客户端,以及你的浏览器API。

所有这些问题都是我们不拥有的东西。我们要么下载它们,通过网络连接到它们,要么它们作为我们代码执行的平台的一部分存在。它们是基础性的

与核心代码不同,我们不对其进行单元测试 1。相反,我们要测试的是它的集成方式--这是一个关键词:集成。我们要测试它如何与我们的核心代码集成

因此,我们写集成测试。

例如,一种特殊类型的集成测试被称为合约测试。这可以确保--例如,我们的数据库适配器能够以我们期望的方式保存和检索对象。

因此,我们需要为我们应用程序的不同方面编写不同类型的测试。

  • 用于测试集成的集成测试
  • 用于测试核心应用功能的单元测试--我们软件中最有价值的方面

是什么阻碍了我们达到可以这样做的地步?

问题:核心代码和基础设施代码的耦合

问题是,当你的核心代码和你的基础设施代码混合在一起时,这其实是不可能的。实际上,更好的说法是相互耦合

如果你的核心代码和你的基础设施代码是耦合的,我们就不能干净地隔离我们代码的一部分,以便编写这些特定类型的测试。

我们最终做的是在RESTful API控制器或GraphQL解析器中耦合了太多的核心代码,突然间,测试应用程序核心功能,是的--我们软件的核心)的唯一方法是把数据库和Web服务器带在身边。这使得事情变得非常缓慢,也使得测试更难设置和拆除。

例子。耦合的功能

这里有一个功能的例子,它的核心和基础结构代码是耦合的。

你可以在GitHub上查看之前和之后的代码。

// modules/users/useCases/createUser/index.ts

import * as express from 'express'
import { User } from '../../domain/user';
import { firebaseUserRepo } from '../../repos';
import { UsersService } from '../../services/usersService'

export async function createUser (req: express.Request, res: express.Response) {
  let body = req.body;

    // Check to see if firstname, lastname, password, email is in the request
    const isFirstNamePresent = body.firstName
    const isLastNamePresent = body.lastName;
    const isEmailPresent = body.email;
    const isPasswordPresent = body.password;

    // If not, end the request
    if (!isFirstNamePresent || !isEmailPresent || !isLastNamePresent || !isPasswordPresent) {
      return res.status(400).json({ 
        message: `Either 'firstName', 'lastName', 'email' or 'password not present`
      })
    }

    // Check to see if already registered
    const existingUser = await firebaseUserRepo.findByEmail(body.email);
        
    // If already registered, return AlreadyRegisteredError
    if (existingUser) {
      return res.status(409).json({
        type: `AlreadyRegisteredError`,
        message: 'User already registered'
      })
    }

    let errorMessage;

    // Validation logic
    if (UsersService.validateFirstName(body.firstName)) {
      errorMessage = 'Invalid firstName';
    }

    if (UsersService.validateLastName(body.lastName)) {
      errorMessage = 'Invalid lastName';
    }

    if (UsersService.validateEmail(body.email)) {
      errorMessage = 'Invalid email';
    }

    if (UsersService.validatePassword(body.password)) {
      errorMessage = 'Invalid password';
    }

    // If invalid props, return InvalidUserDetailsError
    if (errorMessage) {
      return res.status(400).json({
        type: 'InvalidUserDetailsError',
        message: errorMessage
      })
    }

    // Create user
    let user: User = {
      firstName: body.firstName,
      lastName: body.lastName,
      email: body.email,
      password: body.password
    }

    // Save user to database
    try {
      await firebaseUserRepo.save(user);
    } catch (err) {

      // Log this to monitoring or logging plugin but don't return
      // the backend error to the client.

      return res.status(500).json({
        message: 'Unexpected error occurred'
      })
    }

    return res.status(201).json({
      type: 'CreateUserSuccess',
      message: 'Success'
    })
}

这有什么问题吗?嗯,当然没有什么真正的问题。这段代码是有效的。然而,我们测试的唯一方法是进行黑盒测试:端到端测试。为什么?因为为了设置这个测试,我们需要带着整个Express.js webserver,连接到一个真正的firebase实例,而且我们只能通过响应代码来验证应用逻辑

虽然我相信E2E测试应该是你测试策略的一部分,但我不认为它应该你的测试策略。

现在我们来讨论一下解决这个问题的路径。

架构模式

第七部分--来自solidbook.io的架构要点中,我们了解了架构风格架构模式

架构风格是一般的总体方式,你可以在其中构建你的应用程序,以帮助你解决一个特定的架构挑战。三种主要的方式是结构性基于消息的分布式架构。

这有点像我们的设计模式。我们通常认为设计模式是帮助我们解决类层面上的挑战的东西,而架构风格及其模式则帮助我们解决架构层面上的问题

架构挑战?我们在这里面临的挑战是什么?

我们中95%的人在构建非微不足道的生产应用时,所面临的挑战不仅是如何在软件中有效地编码这些业务规则,而且要以一种我们可以验证它们是否有效的方式来做,并且我们可以在以后的道路上安全地改变这些规则。

换句话说,挑战在于,我们有严格的测试要求

哪种架构模式是专门用来帮助我们解决严格的测试要求问题的?啊,是的,分层架构。

一个分层的架构

解决我们的测试问题的方法是一种叫做分层架构的结构性建筑风格。

如果你是这个博客的普通读者,你很可能已经熟悉它了,也许是以六边形架构、洋葱架构或著名的清洁架构为名。

对于那些不熟悉的人来说,其主要思想是,你把你的应用程序的关注点分成几层。

  • 一层为领域逻辑
  • 一层用于应用
  • 一个用于基础设施
  • 还有一个适配器层,它只是描述抽象,使你的基础设施有可能使用依赖注入的方式钩住你的应用层代码。

核心代码与领域、应用和适配器2层相对应,而基础设施与基础设施层相对应。

好处 - 测试选项

这种分离的主要好处是,我们给自己提供了更多的测试选择

以前,如果我们想写验收测试,我们必须以一种E2E测试的方式来写,把我们所有的数据库、服务和真实世界的API都带在身边,甚至可能用真钱来玩。

相反,如果我们想把验收测试写成单元测试,我们现在可以选择这样做。

对于一个用例测试,我们可以模拟和存根出对基础设施的依赖,而只是专注于测试应用程序的核心--确保它在应该时失败,它试图保存到数据库,它试图调用外部API,或在应该时删除一些东西--但没有真正使之发生。

然后,如果我们想测试我们的基础设施是否工作,我们可以在集成测试中单独测试。这只是一个更大的测试策略的一部分。但可惜的是--现在我们有了选择。

让我告诉你我将如何使用分层架构模式重写和验收测试这个功能。

示范

我会从BDD风格的Given-When-Then测试开始。

# modules/users/useCases/createUser/createUser.feature
Feature: Create user

Scenario: Creating a user
  Given I provide valid user details
  When I attempt to create a user
  Then the user should be saved successfully 

Scenario: Invalid password
  Given I provide an invalid password
  When I attempt to create a user
  Then I should get an invalid details error 

然后我写测试。

// modules/users/useCases/createUser/createUser.spec.ts
import { defineFeature, loadFeature } from 'jest-cucumber';
import * as path from 'path';
import { IUserRepo } from '../../repos/userRepo';
import { CreateUser, CreateUserResult } from './createUser'
import { UserRepoSpy } from '../../testObjects/userRepoSpy'

const feature = loadFeature(path.join(__dirname, './createUser.feature'));

defineFeature(feature, test => {
  let result: CreateUserResult;

  let email: string;
  let password: string;
  let firstName: string;
  let lastName: string;

  let createUser: CreateUser;
  let userRepoSpy: UserRepoSpy;

  beforeEach(() => {
    createUser = undefined;
    userRepoSpy = undefined;
  })

  test('Creating a user', ({ given, when, then }) => {
    
    given('I provide valid user details', () => {
      // Arrange
      email = 'khalil@khalilstemmler.com';
      password = 'hello'
      firstName = 'khalil'
      lastName = 'stemmler';

      userRepoSpy = new UserRepoSpy([]);

      createUser = new CreateUser(userRepoSpy);
    });

    when('I attempt to create a user', async () => {
      // Act
      result = await createUser.execute({ email, password, firstName, lastName });
    });

    then('the user should be saved successfully', () => {
      // Assert
      expect(result.type).toEqual('CreateUserSuccess');
      expect(userRepoSpy.getTimesSaveCalled()).toEqual(1);
    });

  });

  test('Invalid password', ({ given, when, then }) => {
    given('I provide an invalid password', () => {
      email = 'khalil@khalilstemmler.com';
      password = ''
      firstName = 'khalil'
      lastName = 'stemmler';

      userRepoSpy = new UserRepoSpy([]);

      createUser = new CreateUser(userRepoSpy);
    });

    when('I attempt to create a user', async () => {
      result = await createUser.execute({ email, password, firstName, lastName });
    });

    then('I should get an invalid details error', () => {
      // Assert
      expect(result.type).toEqual('InvalidUserDetailsError')
      expect(userRepoSpy.getTimesSaveCalled()).toEqual(0);
    });
  });
});

然后我再写用例的实现(担心以后的集成测试)。

// modules/users/useCases/createUser/createUser.ts
import { Result } from '../../../../shared/core/result';
import { UseCase } from '../../../../shared/core/useCase';
import { Email } from '../../domain/email';
import { FirstName } from '../../domain/firstName';
import { LastName } from '../../domain/lastName';
import { Password } from '../../domain/password';
import { User } from '../../domain/user';
import { IUserRepo } from '../../repos/userRepo';

type CreateUserInput = {
  email: string;
  password: string;
  firstName: string; 
  lastName: string;
}

type CreateUserSuccess = {
  type: 'CreateUserSuccess'
}

type AlreadyRegisteredError = {
  type: 'AlreadyRegisteredError';
}

type InvalidUserDetailsError = {
  type: 'InvalidUserDetailsError';
  message: string;
}

type UnexpectedError = {
  type: 'UnexpectedError'
}

export type CreateUserResult = CreateUserSuccess 
  | AlreadyRegisteredError 
  | InvalidUserDetailsError
  | UnexpectedError;

export class CreateUser implements UseCase<CreateUserInput, CreateUserResult> {
  private userRepo: IUserRepo;

  constructor (userRepo: IUserRepo) {
    this.userRepo = userRepo;
  }

  public async execute (input: CreateUserInput): Promise<CreateUserResult> {

    // Check to see if already registered
    const existingUser = await this.userRepo.findByEmail(input.email);
        
    // If already registered, return AlreadyRegisteredError
    if (existingUser) {
      return {
        type: 'AlreadyRegisteredError'
      }
    }

    // Validation logic
    let emailOrError = Email.create(input.email);
    let firstNameOrError = FirstName.create(input.firstName);
    let lastNameOrError = LastName.create(input.lastName);
    let passwordOrError = Password.create(input.password);

    let combinedResult = Result.combine([ 
      emailOrError, firstNameOrError, lastNameOrError, passwordOrError 
    ]);

    if (combinedResult.isFailure) {
      return {
        type: 'InvalidUserDetailsError',
        message: combinedResult.errorValue()
      }
    }

    let userOrError = User.create({
      email: emailOrError.getValue() as Email,
      password: passwordOrError.getValue() as Password,
      firstName: firstNameOrError.getValue() as FirstName,
      lastName: lastNameOrError.getValue() as LastName
    });

    if (userOrError.isFailure) {
      return {
        type: 'InvalidUserDetailsError',
        message: userOrError.errorValue()
      }
    }

    let user = userOrError.getValue() as User;

    // Save user to database
    try {
      await this.userRepo.save(user);
    } catch (err) {

      // Log this to monitoring or logging plugin but don't return
      // the backend error to the client.

      return {
        type: 'UnexpectedError'
      }
    }

    return {
      type: 'CreateUserSuccess'
    }
  }
}

在这个改进的版本中,有许多细微的差别。阅读关于用例测试的文章或查看本文的YouTube视频以了解更详细的细节。

结论

回顾一下,我们了解到。

  • 测试依赖于数据库、API、文件系统或任何基础设施的代码的最大问题是,我们经常将我们的核心代码--实际的应用核心,基本的复杂性--与我们的基础设施代码 联系。
  • 架构风格和模式是对常见架构问题的解决方案。
  • 分层架构对测试很有帮助的原因是,它允许我们使用依赖性反转来分离核心代码和基础设施代码。这给了我们测试的选择。然后我们可以制定一个有效的测试策略。
  • 对你的功能进行验收测试的一种方法是使用[使用],通过使用用例测试,能够测试应用程序的核心,而不依赖基础设施--这非常方便,因为它使我们能够用尽所有的成功和失败状态,而不需要把缓慢的数据库连接或网络请求带过来。

常见问题

问题。我必须这样做吗?

必须这样做来测试我的后端代码吗?当然不是。如果你正在构建一些超级简单的东西,比如CRUD应用,概念验证,或者只是在探索,我认为你不需要达到这个水平。

你总是可以仅仅对你的整个后端进行黑盒测试,这意味着--从API中,你只是向它发送HTTP请求,并假设如果你的数据库被保存了适当的记录,事情就能正常进行。

然而,这确实在我们的测试能力中留下了很多空白。

例如,你究竟如何能够验证确认电子邮件是否在应该发送的时候发送了?你不可能以编程方式登录你的Gmail账户来发现这一点。

我认为你通常希望混合进行黑盒测试--从外部测试,以及一些白盒测试,从内部测试。在任何你打算长期维护的软件上,拥有这两种测试是健康测试策略的一部分。

问题。这只是在后端,还是也适用于前端?

你也可以将你的前端代码的各层解耦。

我已经写了一点关于如何通过客户端架构基础在前端工作的哲学,虽然我有时会使用这些技术,但这取决于测试要求。

例如,如果你正在构建我称之为列表/细节视图的应用程序,你真正要做的就是获取数据并将其呈现出来,那么不--我认为这并不是完全必要的。你的前端测试策略可以只是执行E2E测试,仅此而已(尽管我建议尽可能以BDD-接受-测试的方式编写这些测试)。

然而,如果你正在构建一些非常复杂的东西,比如浏览器中的数字音频工作站,而且有很多逻辑不能通过单纯的感知和点击来测试,那么是的,你会想要执行一套更严格的架构层,用于单元测试应用程序或领域逻辑。

所以最终,这取决于你的测试要求。它们有多严格?你的测试策略是什么?

问题。测试React组件的情况如何?你如何测试它们?

让我们应用同样的哲学。

  1. 理解我们想测试的是什么?
  2. 制定一个测试策略
  3. 将核心代码与基础设施代码分开

这取决于你想做什么。如果你想用单元测试来测试你的纯react组件,那么是的--我们要执行某种程度的分离,特别是如果它依赖于基础设施,如Apollo客户端或支持HTTP的服务类或从RESTful API获取数据的React挂钩。

如果你不太关心测试你的React组件,而更关心从用户层面测试应用程序的功能(对于视图/列表细节应用程序非常常见),这就要求进行E2E测试。而且,这并不要求核心与基础设施的严格解耦。在你的React代码写你的E2E测试吧。甚至更好的是,从使用页面对象模式 开始 写你的E2E测试,然后写最小的React代码来使你的E2E测试通过。


  1. 人们对单元测试什么和不是什么似乎有不同的看法。我不认为测试一个包含其他组件的组件会使我的测试失去单元测试的资格。例如,如果我想测试一个纯粹的React组件,像Table ,它被分解成更小的子组件,像TableRowTableColumn ,我仍然认为测试Table 是一个有效的单元测试。为什么呢?因为我相信单元测试更多的是关于我们是否在测试核心代码或基础设施代码。如果我们测试依赖于数据库、网络请求、文件系统、甚至系统时钟的代码,我们就不再是在测试单元--我们是在测试集成。什么之间的整合?在你写的代码(核心)和别人写的代码(基础设施)之间。这就是我们在验收测试中使用的策略,作为用例测试

  2. 从技术上讲,你可以说适配器层是一个核心层,因为它不包含任何基础设施的关注。它完全是由抽象构成的。我喜欢把它看成是核心和基础设施之间的桥梁。