C#中单元测试的不同类型的实例教程

207 阅读7分钟

有很多不同类型的测试。有很多不同类型的自动化测试。在这篇文章中,我们将谈论单元测试,特别是一些不同类型的单元测试,以及何时你可能想要使用每一种。我们将在C#中使用Nunit的例子。

示例代码

在这篇文章中,我们将使用一些示例代码来讨论部分内容。这段代码接收一个用户名和密码,假设它们与存储的用户名和密码相匹配,将为用户生成一个签名的JWT,并将其存储在一个cookie中。

public class SignInCommand
{
    //Constants and class variables removed for brevity.

    public SignInCommand(ICookieHelper cookieHelper, ITokenGenerator tokenGenerator, IUserRepository userRepository, IPasswordHasher passwordHasher)
    {
        this.cookieHelper = cookieHelper;
        this.tokenGenerator = tokenGenerator;
        this.userRepository = userRepository;
        this.passwordHasher = passwordHasher;
    }

    public async Task<bool> Execute(string username, string password)
    {
        var user = await userRepository.Load(username);
        if (user == null) return false;
        if (!passwordHasher.DoesPasswordMatch(user.Salt, user.Hash, password)) return false;
        var token = tokenGenerator.Encode(username, user.PlayerName);
        cookieHelper.SetCookie(CookieName, token);
        return true;
    }
}

安排-行动-确认

典型的单元测试是指你想以一种单一的方式测试一段代码,你想验证该代码的一个效果是否正确。根据代码的复杂性,在被测试的代码被执行之前,可能需要一些设置。这种为测试设置适当的条件,运行被测试的代码,然后验证结果的模式,通常被称为安排-行动-断言模式。

使用我们上面的示例代码,这里是一个安排-行为-断言单元测试,用于测试用户名有效但提供了错误密码的情况。

public class WhenExecutingSignInWithIncorrectPassword : WithAnAutomocked<SignInCommand>
{
    [Test]
    public void ItShouldFail()
    {
        GetMock<IUserRepository>().Setup(x => x.Load(IsAny<string>())).Returns(Task.FromResult(new User()));
        GetMock<IPasswordHasher>().Setup(x => x.DoesPasswordMatch(IsAny<string>(), IsAny<string>(), IsAny<string>())).Returns(false);

        var result = ClassUnderTest.Execute("username", "incorrect password").Result;

        Assert.That(result, Is.False);
    }
}

WithAnAutomocked<T> 基类只是一个辅助类,它实例化了所提供的类(上面代码中的SignInCommand 类),并将该类的所有构造函数依赖性作为Moq模拟注入。它使用AutoMoq来做这件事。

在这个测试例子中,你可以看到一个强大的安排-行为-断言模式。有代码在一些被测试的代码所依赖的模拟上设置了一些行为(安排)。然后,被测试的代码被执行(act),最后我们检查以确保代码做了应该做的事(assert)。

当你想测试一个特定的情况或一组特定的输入时,这种类型的测试很好。当你想验证关于你的代码执行的一件事时,它也很好。

一个行为,许多断言

有时在测试一段代码时,我们想执行代码,然后对该代码的多个效果进行断言。同样,我们可以创建一个标准的安排-行为-断言式测试来处理这种情况。我们只需要在测试的最后添加多个断言。有些人强烈反对在他们的测试中使用多个断言。我并不完全反对,但我确实认为这是一种气味。多重断言的问题之一是,当测试失败时,很难确定代码的哪一部分实际上已经失败了。如果断言是独立的,但它们一起通过或失败,就有必要借助堆栈跟踪或错误信息来确定代码的哪一部分实际上已经失败。另外,至少在Nunit的情况下,测试中单个断言的失败将导致测试的执行停止。因此,测试早期的失败会掩盖测试后期的其他失败。

幸运的是,有一个解决这个问题的方法。该解决方案是使用一种有时被称为行为驱动开发(BDD)测试或规范测试的风格。

public class WhenExecutingValidSignIn : WithAnAutomocked<SignInCommand>
{
    private bool result;
    private string tokenValue = "token value";

    [SetUp]
    public void SetUp()
    {
        var user = new User { Username = "username", PlayerName = "Player Name" };
        GetMock<IUserRepository>().Setup(x => x.Load("username")).Returns(Task.FromResult(user));
        GetMock<ITokenGenerator>().Setup(x => x.Encode("username", "Player Name")).Returns(tokenValue);
        GetMock<IPasswordHasher>().Setup(x => x.DoesPasswordMatch(IsAny<string>(), IsAny<string>(), IsAny<string>())).Returns(true);
        result = ClassUnderTest.Execute("username", "password").Result;
    }

    [Test]
    public void ShouldSucceed() => Assert.That(result, Is.True);

    [Test]
    public void ShouldSetCookie() => GetMock<ICookieHelper>().Verify(x => x.SetCookie(SignInCommand.CookieName, tokenValue));
}

在这个例子中,我们又回到了SignInCommand 。但在这种情况下,我们要测试的是登录成功的路径。通过这段特殊的代码,我们要确保返回的结果是正确的*,并且*cookie被设置了。这两个结果可以很容易地相互独立地成功或失败。所以把它们作为两个断言放在同一个测试中是没有意义的,原因如上所述。

当一段代码有多种效果,可以独立地成功或失败时,这种类型的测试非常好。在这个特殊的例子中,只有两个这样的断言,但你可以想象更复杂的代码片断可能有更多的断言。这个特殊的例子只是使用Nunit来获得这种测试风格,但也有专门针对这种测试类型的测试框架。.NET中的一些例子包括Machine.SpecificationNunit.Specification

测试案例

有时我们有一些代码,我们想测试很多不同的输入与很多不同的输出相匹配。由于我们之前的示例代码SignInCommand ,并不适合这种类型的测试,让我们用一个不同的例子来测试这种类型的单元测试。想象一下,我们想测试一些代码,它接收一个整数,并返回转换为罗马数字的整数值。对于这种类型的算法代码,我们要提供很多不同的输入,以测试所有不同的罗马数字转换,并验证我们是否得到正确的输出。我们可以为每一个我们想要测试的输入和输出集写一个新的安排-行为-断言式测试。但如果我们这样做,我们会有很多重复的代码。而你应该确保你的测试代码尽可能的可维护,就像你的生产代码一样。

幸运的是,为这些类型的场景编写单元测试也是可能的。Nunit有一个专门针对这种类型的测试的功能。如果你选择的单元测试库没有这样的功能,你总是可以创建某种类型的集合来存储你的输入输出对,然后在该集合上迭代。这并不像单元测试库中内置的功能那样灵活,但它仍然可以删除重复的代码,并允许你快速添加新的测试案例。

public class RomanNumeralConversionTests
{
    [TestCase(0, "")]
    [TestCase(1, "I")]
    [TestCase(2, "II")]
    [TestCase(4, "IV")]
    [TestCase(5, "V")]
    [TestCase(6, "VI")]
    [TestCase(10, "X")]
    [TestCase(9, "IX")]
    [TestCase(40, "XL")]
    [TestCase(50, "L")]
    [TestCase(90, "XC")]
    [TestCase(100, "C")]
    [TestCase(400, "CD")]
    [TestCase(500, "D")]
    [TestCase(900, "CM")]
    [TestCase(1000, "M")]
    public void ConvertTests(int arabic, string roman)
    {
        Assert.That(ArabicToRoman.Convert(arabic), Is.EqualTo(roman));
    }
}

在这个例子中,你可以看到,如果我们对每个输入和输出的组合都有不同的安排-行为-断言式测试,就会有很多重复的代码。而且每次我们想添加一个额外的测试用例时,都会涉及到重复的代码。使用Nunit的TestCase 属性,我们可以删除大量的重复代码,并使将来添加测试案例变得容易。我们也能够更清楚地表明我们要测试的是什么。我们不是要测试1 是否一定会转换为I 。我们真正想测试的是整个转换过程是否符合预期。把所有与转换有关的测试放在一起,使用这样的测试用例,使我们能够更好地显示我们的意图。

你应该使用哪种类型的单元测试?

上面描述的三种类型的单元测试在它们自己的环境中都很好。如果你测试的代码有多个效果,可以独立地成功或失败,规范测试可能是一个很好的选择。如果你正在测试的代码,你想确保多组输入和输出是正确的,测试案例风格的测试可能是一个不错的选择。当有疑问时,一个标准的安排-行为-断言测试是一个很好的开始。

记住,测试代码也是代码。它应该被精心设计,就像生产代码一样。它应该删除重复部分,应该重构以提高可维护性,并且应该随着时间的推移而发展。希望这里给出的例子能给你一些想法,帮助你的测试代码变得比现在更好。