即使是简单的行为测试也会导致测试功能的丛林

Testing of even easy behaviour leads to a jungle of testing functions

直到今天我都很难进行单元测试。为此,我刚开始看书"The art of Unit Testing"
作者声明每个“工作单元”都有入口点和出口点,每个出口点都应该有一个单元测试。
“退出点”可以是:

入口点通常是函数调用。

我现在很想在我的一个例子中尝试这个,我成功了。但是对于一个我无法接受的价格。我的测试有很多功能,我想听听您对它们的看法。

我想用的测试class很简单:

public class RoleAssignement
{
    public string RoleId { get; }
    public string EnterpriseId { get; }
    public List<string> SiteIds { get; }

    public RoleAssignement(string roleId, string enterpriseScopeId)
    {
        Ensure.ThrowIfNull(roleId);
        Ensure.ThrowIfNull(enterpriseScopeId);
        Ensure.ThrowIfIdNotValid(roleId);
        Ensure.ThrowIfIdNotValid(enterpriseScopeId);

        RoleId = roleId;
        EnterpriseId = enterpriseScopeId;
    }

    public RoleAssignement(string roleId, List<string> siteScopeIds)
    {
        Ensure.ThrowIfNull(roleId);
        Ensure.ThrowIfNull(siteScopeIds);
        Ensure.ThrowIfIdNotValid(roleId);
        foreach(var id in siteScopeIds)
        {
            Ensure.ThrowIfIdNotValid(id);
        }
        RoleId = roleId;
        SiteIds = siteScopeIds;
    }
}

你可以看到我只有三个属性。其中之一 (RoleId) 必须始终设置。另外两个参数必须独占设置(一个为空则必须设置另一个,反之亦然)。

在本书的语言中,我有两个“切入点”——我的两个构造函数。

但是我有十个退出点是:

1 If roleId is null for the first constructor, an exception should be thrown.  
2 If roleId is null for the second constructor, an exception should be thrown.  
3 If enterpriseScopeId is null for the first constructor, an exception should be thrown.  
4 If siteScopeId is null for the second constructor, an exception should be thrown.  
5, 6, 7, 8 If any of the four parameters have an invalid id, an exception should be thrown.  
9 If the first constructor was called with the correct parameters, RoleId and EnterpriseId should be set but SiteIds should be null.  
10 If the second constructor was called => vice versa.  

如果我现在编写单元测试,我会得到一长串测试函数 - 每个退出点都有一个。我把这个贴在了我的问题的最后。

问题是:我真的走对了吗?我现在有一个非常简单的测试 class,它的测试似乎在巨大的测试丛林中爆炸。
用自己的函数测试每个可能的退出点会减慢我的编码速度,因为测试正在测试这样一个简单的行为,它们对我来说也毫无价值。

他们是吗?

但是如果我开始测试更复杂的东西会发生什么?我将有一个项目有 1000 行生产代码和 10000 行繁琐的测试功能。

我想我误解了什么,但我不知道我误解了什么。或者我可以接受我的测试并且从现在开始我必须忍受这个吗?

这是我的测试代码 - 所有测试都通过了。我什至按照 TDD 编写了它们:

测试代码:~100 行
生产代码:~30 行

真的吗?

using CP.Admin.Core.SDK.ValueObjects;
using DataHive.Validations.Exceptions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;

namespace CP.Admin.Tests
{
    [TestClass]
    public class RoleAssignementTests
    {
        [TestMethod]
        public void RoleAssignementFirst_NullRoleIdParameter_ThrowsArgumentNullException()
        {
            Action useConstructor = () =>
            {
                var roleAssignement = new RoleAssignement(null, "62500ac55988223c8b9b28fc");
            };
            Assert.ThrowsException<ArgumentNullException>(useConstructor);
        }
        [TestMethod]
        public void RoleAssignementFirst_InvalidRoleIdParameter_ThrowsIdNotValidException()
        {
            Action useConstructor = () =>
            {
                var roleAssignement = new RoleAssignement("invalidId", "62500ac55988223c8b9b28fc");
            };
            Assert.ThrowsException<IdNotValidException>(useConstructor);
        }
        [TestMethod]
        public void RoleAssignementFirst_NullEnterpriseScopeIdParameter_ThrowsArgumentNullException()
        {
            Action useConstructor = () =>
            {
                string param = null;
                var roleAssignement = new RoleAssignement("62500ac55988223c8b9b28fc", param);
            };
            Assert.ThrowsException<ArgumentNullException>(useConstructor);
        }
        [TestMethod]
        public void RoleAssignementFirst_InvalidEnterpriseScopeIdParameter_ThrowsArgumentNullException()
        {
            Action useConstructor = () =>
            {
                var roleAssignement = new RoleAssignement("62500ac55988223c8b9b28fc", "invalidId");
            };
            Assert.ThrowsException<IdNotValidException>(useConstructor);
        }
        [TestMethod]
        public void RoleAssignementSecond_NullRoleIdParameter_ThrowsArgumentNullException()
        {
            Action useConstructor = () =>
            {
                var param = new List<string> { "62500ac55988223c8b9b28fc" };
                var roleAssignement = new RoleAssignement(null, param);
            };
            Assert.ThrowsException<ArgumentNullException>(useConstructor);
        }
        [TestMethod]
        public void RoleAssignementSecond_InvalidRoleIdParameter_ThrowsIdNotValidException()
        {
            Action useConstructor = () =>
            {
                var param = new List<string> { "62500ac55988223c8b9b28fc" };
                var roleAssignement = new RoleAssignement("invalidId", param);
            };
            Assert.ThrowsException<IdNotValidException>(useConstructor);
        }
        [TestMethod]
        public void RoleAssignementSecond_NullSiteScopeIdParameter_ThrowsArgumentNullException()
        {
            Action useConstructor = () =>
            {
                List<string> param = null;
                var roleAssignement = new RoleAssignement("62500ac55988223c8b9b28fc", param);
            };
            Assert.ThrowsException<ArgumentNullException>(useConstructor);
        }
        [TestMethod]
        public void RoleAssignementSecond_InvalidSiteScopeIdParameter_ThrowsIdNotValidException()
        {
            Action useConstructor = () =>
            {
                var param = new List<string> { "invalidId" };
                var roleAssignement = new RoleAssignement("62500ac55988223c8b9b28fc", param);
            };
            Assert.ThrowsException<IdNotValidException>(useConstructor);
        }
        [TestMethod]
        public void RoleAssignementFirst_ParametersAreOkay_AllValuesAreCorrect()
        {
            var roleAssignement = new RoleAssignement("62500ac55988223c8b9b28fc", "62500ac55988223c8b9b28fc");
            Assert.IsNotNull(roleAssignement.RoleId);
            Assert.IsNotNull(roleAssignement.EnterpriseId);
            Assert.IsNull(roleAssignement.SiteIds);
        }
        [TestMethod]
        public void RoleAssignementSecond_ParametersAreOkay_AllValuesAreCorrect()
        {
            var param = new List<string> { "62500ac55988223c8b9b28fc" };
            var roleAssignement = new RoleAssignement("62500ac55988223c8b9b28fc", param);
            Assert.IsNotNull(roleAssignement.RoleId);
            Assert.IsNotNull(roleAssignement.SiteIds);
            Assert.IsNull(roleAssignement.EnterpriseId);
        }
    }
}

如你所知,走这条路会很痛苦。因为想要为每种可能的情况(每个参数值 + 每个可能的组合)断言将需要(如您所见)比使实际生产代码工作更多的工作。

所有这些都是因为您正在针对 数据.

进行定向测试

如果您考虑改为测试系统的行为,您可以摆脱大量实施细节,专注于更高层次。

考虑到行为,我最终能看到的唯一一个是

The other two parameters should be exclusively set (if one is null, the other must be set and vice versa).

根据你的编号对应场景9和10:

[TestMethod]
public void RoleAssignementFirst_ParametersAreOkay_AllValuesAreCorrect()
{
    var roleAssignement = new RoleAssignement("62500ac55988223c8b9b28fc", "62500ac55988223c8b9b28fc");
    Assert.IsNotNull(roleAssignement.RoleId);
    Assert.IsNotNull(roleAssignement.EnterpriseId);
    Assert.IsNull(roleAssignement.SiteIds);
}

[TestMethod]
public void RoleAssignementSecond_ParametersAreOkay_AllValuesAreCorrect()
{
    var param = new List<string> { "62500ac55988223c8b9b28fc" };
    var roleAssignement = new RoleAssignement("62500ac55988223c8b9b28fc", param);
    Assert.IsNotNull(roleAssignement.RoleId);
    Assert.IsNotNull(roleAssignement.SiteIds);
    Assert.IsNull(roleAssignement.EnterpriseId);
}

与生产代码库相比,现在测试代码库的比例比以前小得多,这是一个更好的折衷方案,因为它以大大降低的价格(在实施和维护方面)测试最重要的东西。

更进一步

让我让您看到一些您可能从未想过会成为可能的事情。 RoleAssignment 可能需要 无测试 并且仍然通过更好地使用类型系统来执行与您想要的规则相同的规则。

考虑以下代码:

public class RoleAssignement
{
    public Id RoleId { get; }
    public Either<Id, List<Id>> RelatedIds { get; }

    public RoleAssignement(Id roleId, Either<Id, List<Id>> relatedIds)
    {
        RoleId = roleId;
        RelatedIds = relatedIds;
    }
}

我使用了一个名为 Value Object to get rid of primitive types 的模式。这些值对象(IdEither)封装了 Id 被视为有效的所有验证。当提供给 RoleAssignement 构造函数时,您就可以确定您正在处理正确的值。 RoleAssignement 不需要更多测试,类型系统已经强制执行您的约束!

然后您可以从场景中提取测试以仅测试值对象构造一次。这意味着即使在整个代码库中到处都使用 Id,也只需要测试一次。