Golang 单元测试最佳实践

2024-01-09   出处: hashnode.dev  作/译者:Tay Nguyen/暖阳

在后端工程中,我们经常要解决的一个问题就是编写单元测试用例。在本文中,我们将探讨在 Go语言中编写有效单元测试用例的技巧,讨论编写单元测试的最佳实践,并利用 mock 实现更好的隔离。虽然我们的主要关注点是单元测试相关的实践,但值得注意的是,Golang 也支持集成测试。在未来的文章中,我们还将讨论集成测试的主题,详细介绍在Golang中进行集成测试的细节和最佳实践。

引言

单元测试在软件开发中的重要性

单元测试在软件开发中至关重要,可以捕捉错误和缺陷,确保可维护性和模块化,提高安全性,改善软件的整体质量。随着网络安全威胁的增加,为确保软件系统的安全可靠,单元测试变得越来越重要。

以下是采用单元测试所获得的一些好处(这并不是一个详尽的列表):

  • 单元测试能更早地发现和解决错误
  • 单元测试为开发人员提供保障:全面的单元测试可以为开发人员提供保障。通过频繁运行测试,开发人员可以确保最近对代码的修改没有引入问题。
  • 单元测试有助于提高代码质量:这是前一点的自然结果。由于单元测试提供了一层保障,开发人员在修改代码时更有信心。他们可以进行代码重构而不担心引入问题,从而提高了代码的整体质量。
  • 检测代码中的问题:如果说在代码中很容易添加单元测试是个好兆头,那么反之亦然。如果很难为某段代码创建单元测试,这可能是代码中存在问题的迹象,比如函数过于复杂。

Golang 单元测试框架概述

Golang的测试包提供了一个易于使用的框架,用于创建单元测试、基准测试和示例,并通过命令行执行来简化 Golang 的开发流程。测试包允许各种类型的测试,包括性能测试、并行测试、功能测试以及它们的任意组合。

在Golang中编写测试套件的步骤:

  • 创建一个以_test.go结尾的文件。
  • 通过import “testing”命令导入测试包。
  • 编写形式为func TestXxx(testing.T)的测试函数,使用Error、Fail或相关方法来表示测试失败。
  • 将文件放置在任何包中。
  • 运行go test命令。

以下是一个测试文件的示例:

package main

import (
    "testing"
)

// test function
func TestYourFunc(t *testing.T) {
    actualString := YourFunc()
    expectedString := "dwarvesv"
    if actualString != expectedString{
        t.Errorf("Expected String(%s) is not same as"+
        " actual string (%s)", expectedString,actualString)
    }
}

编写有效测试的策略

使代码可测试且易于测试

在开发代码项目时,开发人员通常会花费大量时间用于选择合适的框架、库、数据库和其他第三方组件,而有时会忽视测试的重要性。

实际上,适当的测试能让项目变得更好,因为它能鼓励你做到以下几点:

  • 应用简洁的代码:编写简短的函数,每个函数只处理一项任务等。
  • 通过使用抽象、接口和mock,编写可扩展和不依赖特定环境的代码。
  • 通过测试常规/边缘Case和高覆盖率来更好地理解业务逻辑。
  • 避免维护困难、长期未修改且难以维护的代码—测试将简化代码维护和验证更改的过程,使其不会变成陈旧。

编写清晰简洁的测试用例

一个好的测试用例最重要的一点是易于阅读和维护,它应该被视为与实现代码同等重要:

命名测试用例

测试名称应由三部分组成:

  • 被测试方法的名称。
  • 进行测试的场景。
  • 调用该场景时的预期行为。

示例:

  • 不好的命名:错误1​,无效输入1​,用例1
  • 好的命名:当输入单个数字时应返回相同的数字​,当输入字符串时应返回0

表格驱动测试

如果要测试的函数处理过多任务时,尤其是要测试许多不同的情况,那么测试用例很快就会变得难以阅读、重复和令人讨厌,例如:

package main

import (
   "github.com/stretchr/testify/assert"
   "testing"
)

func TestHadAGoodGame(t *testing.T) {
   tests := []struct {
      name     string
      stats   Stats
      goodGame bool
      wantErr  string
   }{
      {"sad path: invalid stats", Stats{Name: "Sam Cassell",
         Minutes: 34.1,
         Points: -19,
         Assists: 8,
         Turnovers: -4,
         Rebounds: 11,
         }, false, "stat lines cannot be negative",
      },
      {"happy path: good game", Stats{Name: "Dejounte Murray",
         Minutes: 34.1,
         Points: 19,
         Assists: 8,
         Turnovers: 4,
         Rebounds: 11,
      }, true, ""},
   }
   for _, tt := range tests {
      isAGoodGame, err := hadAGoodGame(tt.stats)
      if tt.wantErr != "" {
         assert.Contains(t, err.Error(), tt.wantErr)
      } else {
         assert.Equal(t, tt.goodGame, isAGoodGame)
      }
   }
}

此时可以考虑将相关测试用例封装到表格中,使用表格驱动的方式去解决这一问题。

使用接口,避免文件 I/O、API 调用

在编写测试用例时,重要的是尽可能使用接口并避免文件 I/O 和 API 调用。期望测试用例:执行快速、独立、隔离、一致且可靠。以下是一些需要牢记的最佳实践:

使用接口

By using interfaces, you can decouple your code from its dependencies, making it easier to test in isolation. Instead of calling concrete implementations, you can call interfaces that define the behavior you need, for example:

通过使用接口,您可以将代码与其依赖关系解耦,从而更容易进行隔离测试。您可以调用定义了所需行为的接口,而不是调用具体实现,例如:

type repository interface {
  GetRecipe(recipeID string) (domain.Recipe, error)
  CreateRecipe(recipe domain.Recipe) error
  UpdateRecipe(recipe domain.Recipe) error
}

Mock依赖

要测试依赖于外部资源的代码,可以使用mock来模拟这些依赖关系的行为。通过这种方法,您可以在无需依赖外部资源的情况下,对代码进行独立测试。

import (
    "testing"

    "github.com/stretchr/testify/assert"
)

func Test_getFromDB(t *testing.T) {
    mockDB := NewMockDB(t)
        mockDB.On("GetFlavor").Return("Chocolate", nil)
    flavor := getFromDB(mockDB)
    assert.Equal(t, "chocolate", flavor)
}

避免文件 I/O

文件 I/O 可能既慢又不可靠,因此很难测试依赖于它的代码。相反,可以考虑使用接口来抽象文件 I/O,并在测试过程中使用mock来模拟文件操作。

func Test_getFromDB(t *testing.T) {
        mockReader := reader.NewMock()
        mockReader.On("Read", mock.Anything).Return(100, nil)
    scanSvc := scan.NewInstance(mockReader)

        expectedSize := 100
    assert.Equal(t, scan.size(), expectedSize)
}

避免API调用

与文件 I/O 一样,API 调用可能既慢又不可靠,因此很难测试依赖于它们的代码。相反,可以考虑使用接口来抽象 API 调用,并在测试过程中使用mock来模拟 API 响应。

func Test_AcceptJobRequest(t *testing.T) {
        mockEmailGwy := new(email.MockGateway)
        mockEmailGwy.On("SendEmailWithTemplate", mock.Anything, mock.Anything).Return(nil)

        workerCtrl := worker.New(mockEmailGwy)
        err := workerCtrl.acceptAndNotify()
        // Some asserts here
}

覆盖边缘情况和边界条件

众所周知,这是一种基本的测试策略,但却能发现大部分潜在的错误。因为人类通常会打破常规,这将破坏正常的流程。覆盖边缘情况和边界条件非常重要,以确保您的代码能够处理极端或意外值。以下是一些关于如何覆盖测试中的边缘情况和边界条件的提示:

  • 测试极端值:确保测试极端值,例如代码可以处理的最大值和最小值。
  • 测试意外输入:确保测试任何奇怪的输入值或可能看起来会影响测试的字符。
  • 测试边界情况:确保测试边界情况,例如多个输入或条件相交的情况。这种方法可以帮助您发现复杂逻辑或代码不同部分之间的交互问题。

测试覆盖率

测试覆盖率是软件测试中的一项度量指标,用于衡量一组测试所执行的测试程度。它收集在运行测试套件时执行了程序的哪些部分的信息,以确定已经执行了条件语句的哪些分支。

简单地说,测试覆盖率是一种技术,用于确保测试用例正在测试您的代码,或者通过运行测试来检查您代码的覆盖度。

  • 非常差:0-20%的覆盖率。这意味着很少或没有编写单元测试来测试代码,这可能导致未发现的错误和缺陷。
  • 差:21-40%的覆盖率。这意味着已编写了一些单元测试,但仍有大量代码未经测试。
  • 可接受:41-60%的覆盖率。这意味着已编写了一定数量的单元测试来测试代码,但仍有改进的余地。
  • 良好:61-80%的覆盖率。这意味着单元测试已经覆盖了很大一部分代码,大多数潜在的错误都已被发现。
  • 非常好:81%-100%的覆盖率。这意味着单元测试几乎覆盖了所有代码,出现漏洞和错误的可能性非常低。不过,根据代码的性质和复杂程度,达到 100% 的覆盖率并不总是切实可行或必要的。

虽然这取决于项目的状态,但理想情况下,我们建议将测试覆盖率设定在61%-80%之间。不过,不要迷恋这个数字,我们的首要目标是编写能帮助我们有效捕捉错误的测试。

工具和库

Golang拥有一个强大的测试框架;然而,使用辅助工具可以增强开发体验并减少代码创建工作。

Mock:与其手动创建mock代码,不如考虑使用mock库,例如 gomock 或 mockery,它们支持mock并从接口生成mock,从而减少时间消耗。

Assert:默认的 Golang 测试框架的断言功能有限。这时,像 testify 这样的工具可以提供更好的支持和更友好的断言。

结论

在本文中,我们介绍了 Golang 单元测试的基础知识,并探讨了编写有效和易于维护的单元测试的一些策略。我们讨论了一些最佳时间,使用接口,避免文件I/O和API调用,自动化单元测试以及覆盖边界情况和边界条件。

通过遵循这些策略和最佳实践,您可以编写更易于运行、理解和维护的测试。通过在单元测试上投入时间,您可以在开发过程中更早地发现错误,从而节省时间并提高代码的整体质量。

请记住,测试不是一次性任务,而是一个持续的过程,应融入您的开发工作流程中。有了正确的工具、框架和思维方式,测试就能成为开发流程中自然而有价值的一部分,帮助您构建更可靠、更可维护的软件。


声明:本文为本站编辑转载,文章版权归原作者所有。文章内容为作者个人观点,本站只提供转载参考(依行业惯例严格标明出处和作译者),目的在于传递更多专业信息,普惠测试相关从业者,开源分享,推动行业交流和进步。 如涉及作品内容、版权和其它问题,请原作者及时与本站联系(QQ:1017718740),我们将第一时间进行处理。本站拥有对此声明的最终解释权!欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,与我们的编辑和其他窝友交流。
149° /1494 人阅读/0 条评论 发表评论

登录 后发表评论