Java 单元测试简介

2025-05-24

Java 单元测试简介

_封面图片由 [Hans-Peter Gauster](https://unsplash.com/@sloppyperfectionist) 在 [Unsplash](https://unsplash.com/) 上发布_

我们都经历过这样的情况:我们在一个项目上投入了大量时间,确保运行所有可能想到的场景,力求让项目尽可能完美。但当你让别人尝试时,他们会发现一些小的异常情况,让你的应用出现意想不到的行为。

为了防止这种情况频繁发生,我们使用不同类型的测试技术:单元测试、集成测试和端到端测试。后者(端到端测试)可以由开发人员或质量保证团队成员进行。

这也被称为测试金字塔

测试金字塔

尽量不要被前面提到的另外两种测试类型分散注意力,而要专注于这样一个想法:我们的单元测试在数量上会比其他测试多得多,但请记住,它们不能互相替代。它们都很重要

闲聊够了,我们开始编码吧,好吗?

要求

  • 在您的机器上安装 JDK(呃!)。
  • IDE(我推荐 IntelliJ IDEA 社区版)。
  • 就 Java 语言而言,您需要熟悉variables、、和的概念才能完全理解这篇文章。(我或许可以在这方面constant提供帮助)。functionclassobject
  • 将此GitHub Gistclass中的数学添加到您的项目中。

入门

我们首先要做的就是将 TestNG 框架添加到我们的项目中。这将为我们提供一套classes稍后annotations会用到的。

将 TestNG 添加到您的项目

您可以通过两种方式完成:手动安装或在项目中使用 Maven。您可以根据自己的项目设置跳过第二种方式。

手动

您可以按照本指南手动添加它。

通过 Maven

  1. 打开你的pom.xml,它应该位于你的项目的根文件夹中。
  2. 在标签内<project>,制作一个<dependencies>标签。
  3. 添加以下 XML 代码块:
<dependency>
    <groupId>org.testng</groupId>
    <artifactId>testng</artifactId>
    <version>6.14.2</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

如果你还没有这样做,请务必选择Enable auto-import屏幕右下角显示的选项。这将允许 IntelliJ IDEA 自动检测你的任何更改pom.xml并进行相应的刷新。救命啊 :)

测试对象

为了这篇文章的目的,我们不会应用 TDD(测试驱动开发)技术,我们的重点将放在了解如何编写测试类和方法上。

因此,为了开始,我们需要在目录class中创建一个src/test/java/,并将其命名为MathTests

在测试名称末尾使用后缀“Tests”class是一种常见的命名约定,它可以让其他开发人员和您自己快速知道,无需打开文件,它里面就有测试逻辑。

看起来应该是这样的:

public class MathTests {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

您能发现常规的class和这个有什么不同吗?

没有。

这很好。

构成class“测试类”的不是它的签名,而是它的方法,并且这些类位于标记为测试源根的目录中非常重要。

这些方法应该遵循特定的命名约定,应该具有public访问修饰符,应该具有void返回类型以及@Test签名前的注释。

注释@Test向我们的 IDE 的测试运行器表明这不是一个常规方法,并将相应地执行它。

因此,我们的第一个测试方法将是这样的:

@Test
public void add_TwoPlusTwo_ReturnsFour() {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

添加测试逻辑

在其他编程语言中还有另一种非常常见的约定,称为 Triple A:

  1. 安排:由几行代码组成,用于声明和初始化我们在测试中需要的对象。
  2. 动作:通常是几行代码,我们用它们来执行动作,无论是某些计算还是修改对象的状态。
  3. 断言:通常由一行代码组成,用于验证Act部分的结果是否成功。将actual结果值与expected我们计划获取的值进行比较。

实际上,这将是:

@Test
public void add_TwoPlusTwo_ReturnsFour() {
    // Arrange
    final int expected = 4;

    // Act
    final int actual = Math.add(2, 2);

    // Assert
    Assert.assertEquals(actual, expected);
}
Enter fullscreen mode Exit fullscreen mode

关键字final用于防止我们的值以后被改变,也称为常量。

在这里我们可以看到整个安排、行动和断言是如何结合在一起的。

如果您查看add_TwoPlusTwo_ReturnsFour()方法左侧的行号,会看到一个绿色的播放按钮,选择它,然后从上下文菜单中选择第一个选项。

等待片刻...测试运行器面板将打开并显示测试结果。

如果您看到一切都是绿色,则表示我们的测试通过了!

但是,作为一名优秀的测试人员,我们也应该尝试让我们的测试失败。

让我们改变行为部分,使其将3和加2在一起,这样我们的actual值就变成了5,我们的测试就会失败。

...

失败了吗?

伟大的!

现在,你们中的一些人可能想知道为什么我们使用assertEquals()Assert 中的方法class,我们可以手动尝试使用if-else可以模拟相同结果的块,但 Assertclass提供了一组方便的方法来执行各种类型的验证。

最常见的是:

  • assertTrue():评估给定的conditionboolean,如果它的值为true,则测试将被标记为通过,否则,它将被标记为失败。
  • assertFalse():类似于assertTrue(),但每当conditionboolean为时,false测试将被标记为PASSED,否则,它将被标记为FAILED。
  • asssertEquals():通常用于比较两个给定值,可以是原始类型(int、double 等)或任何对象。

如果我们使用 来实现自己的逻辑if-else,不仅会使代码变得混乱,还可能导致不必要的结果。因为,如果我们忘记throw在某个if-else代码块中执行 和 并抛出异常,那么我们的两条代码路径都会被标记为 PASSED。

提示:大多数情况下,每个测试应该只使用一个 Assert 方法,尽管也有例外。但通常建议这样做,以确保测试代码简洁明了,直奔主题。每个测试每次只验证一条代码路径。此外,如果我们有 3 个断言,第一个断言失败,后面的断言将永远不会执行,请记住这一点。

现在我们已经解决了这个问题,让我们继续进行更多测试!

您如何练习测试该add()方法的更多场景?

  • 一个正数和一个负数相加。
  • 添加两个负数。

如果我们再看一下我们的数学class,我们会发现还有两种方法。

我会让你对multiply()(提示:确保在将数字乘以零时进行测试)方法进行测试,我将在divide()本文的其余部分重点介绍该方法。

方法divide()

让我们仔细看看这个方法:

public static double divide(int dividend, int divisor) {
    if (divisor == 0)
        throw new IllegalArgumentException("Cannot divide by zero (0).");

    return dividend / divisor;
}
Enter fullscreen mode Exit fullscreen mode

如您所见,如果divisor参数的值为0,我们将抛出IllegalArgumentException,否则将执行除法运算。

注意:该throw关键字不仅会抛出给定的异常,还会停止代码执行,因此它的工作方式类似于break循环或switch块内的关键字。

所以,这个方法有两种可能的结果,或者说“代码路径”。我们需要确保对它们进行测试。

每种方法的测试数量应该等于或大于其代码路径的数量。

这意味着我们至少应该进行 2 次测试。

让我们继续制作它们吧!

  • 将两个数相除,其中divisor为除零 (0) 以外的任意数。
  • 将两个数相除,其中divisor为零 (0)。

我们的第一个测试将是这样的:

@Test
public void divide_TenDividedByFive_ReturnsTwo() {
    final double expected = 2.0;

    final double actual = Math.divide(10, 5);

    Assert.assertEquals(actual, expected);
}
Enter fullscreen mode Exit fullscreen mode

我们的第二个测试是:

@Test(expectedExceptions = IllegalArgumentException.class)
public void divide_TenDividedByZero_ThrowsIllegalArgumentException() {
    Math.divide(10, 0);
}
Enter fullscreen mode Exit fullscreen mode

等一下!

读者先生/女士:“但是,安排、行动和断言是怎么回事?角色在expectedExceptions做什么?”

别担心,我很快就会解释的!

  1. 我决定跳过整个安排、执行和断言步骤,因为当方法运行时,代码的执行会自动中断divide()。因此,对于这个测试来说,整个三重 A 可以省略。
  2. 需要该expectedException部分来告诉我们的测试运行器,IllegalArgumentException在这个测试中实际上有可能发生这种情况,如果我们将其更改为另一个异常,我们的测试就会失败。

.class提示:请记住在异常名称末尾使用,否则,此代码将无法编译。

测试对象

你已经注意到,到目前为止,我们一直在测试 Math 的静态方法class,这意味着我们不必创建它的对象。这很好。

但是如果我们没有class静态方法怎么办?

为此,我们的测试框架(TestNG)提供了一对注释,以确保我们的每个测试都使用我们的一个新实例class

假设我们可以创建 Math 的实例class

在这种情况下,我们的测试将如下所示:

@Test
public void add_TwoPlusTwo_ReturnsFour() {
    final Math math = new Math();
    final int expected = 4;

    final int actual = Math.add(2, 2);

    Assert.assertEquals(actual, expected);
}

@Test
public void divide_TenDividedByFive_ReturnsTwo() {
    final Math math = new Math();
    final double expected = 2.0;

    final double actual = Math.divide(10, 5);

    Assert.assertEquals(actual, expected);
}
Enter fullscreen mode Exit fullscreen mode

这还不算太糟,但请记住,我们可以对此进行更多测试,class并且一遍又一遍地初始化这个数学对象会产生更多的代码噪音。

如果我们必须忽略测试的某些部分,特别是在安排中,这意味着我们可以使用我们的测试框架工具之一:

@BeforeMethod 和 @AfterMethod

这两个注释可以添加到我们的测试函数中,就像我们一直使用的那样@Test,但它们以特定的方式工作。

  • @BeforeMethod:此代码块将始终在任何其他方法之前执行。@Test
  • @AfterMethod:此代码块总是在任何其他方法之后执行。@Test

那么,我们为什么要使用它们呢?

在我们的所有@Test方法中,我们都必须不断地启动一个新的数学对象,因此在@BeforeMethod注释的帮助下,我们可以摆脱这种重复的代码。

我们需要做的第一件事是将 Math 对象提升为成员变量或属性。

public final class MathTests {
    private Math math;

    @Test
    public void add_TwoPlusTwo_ReturnsFour() {
        final int expected = 4;

        final int actual = math.add(2, 2);

        Assert.assertEquals(actual, expected);
    }

    @Test
    public void divide_TenDividedByFive_ReturnsTwo() {
        final double expected = 2.0;

        final double actual = math.divide(10, 5);

        Assert.assertEquals(actual, expected);
    }
}
Enter fullscreen mode Exit fullscreen mode

然后添加我们的@BeforeMethod函数,通常命名为“setUp”。

public final class MathTests {
    private Math math;

    @BeforeMethod
    public void setUp() {
        math = new Math();
    }

    @Test
    public void add_TwoPlusTwo_ReturnsFour() {
        final int expected = 4;

        final int actual = math.add(2, 2);

        Assert.assertEquals(actual, expected);
    }

    @Test
    public void divide_TenDividedByFive_ReturnsTwo() {
        final double expected = 2.0;

        final double actual = math.divide(10, 5);

        Assert.assertEquals(actual, expected);
    }
}
Enter fullscreen mode Exit fullscreen mode

现在,为了确保清除我们的对象,我们可以在函数内部math设置它的值,通常称为null@AfterMethodtearDown()

public final class MathTests {
    private Math math;

    @BeforeMethod
    public void setUp() {
        math = new Math();
    }

    @Test
    public void add_TwoPlusTwo_ReturnsFour() {
        final int expected = 4;

        final int actual = math.add(2, 2);

        Assert.assertEquals(actual, expected);
    }

    @Test
    public void divide_TenDividedByFive_ReturnsTwo() {
        final double expected = 2.0;

        final double actual = math.divide(10, 5);

        Assert.assertEquals(actual, expected);
    }

    @AfterMethod
    public void tearDown() {
        math = null;
    }
}
Enter fullscreen mode Exit fullscreen mode

这意味着我们的测试的执行顺序将是:

  1. setup()
  2. add_TwoPlusTwo_ReturnsFour()
  3. 然后tearDown()
  4. setup()再次。
  5. divide_TenDividedByFive_ReturnsTwo()
  6. 然后tearDown()再说一遍。

就这样吧

通过这个,您现在应该更加熟悉单元测试的工作原理。

虽然我们没有做任何需要使用assertTrue()和的测试assertFalse(),但我鼓励您做自己的测试来稍微尝试一下它们:)

如果您有任何疑问,请随时发表评论,我会尽力解答!

如果您想查看整个项目,请转到GitHub 上的这个存储库

文章来源:https://dev.to/chrisvasqm/introduction-to-unit-testing-with-java-2544
PREV
让我们谈谈跨域资源共享(CORS)
NEXT
大多数开发人员不知道您经常使用的一种资源是什么?