单元测试布道之一:定义、分类与策略

在开始之前

即便从业若多年,不写单元测试的开发人员并不少见。关于单元测试的相关知识和实践网上连篇累牍,无须从零开始陈述,本系列预计三四章,本单为序,部分内容来自网上资料整理,后续内容添加自行编写的内容,出处见于文章末尾,请自行取用。

什么是单元测试

关于测试概念非常多,在进行定义之前有必要先对测试进行分类,避免大家使用相同术语表达不同的意思。

测试的分类

软件测试从不同的角度审视有着不同的分类方式,比如按测试方法有“黑盒”、“白盒”之分,按按测试方向有功能、性能、安全、兼容性、稳定性测试。开发人员关注按阶段分类的测试,列举如下。

  • 单元测试:检查代码判断是否有问题
  • 集成测试:测试模块和模块的连接有没有问题
  • 系统测试:测试软件的整个整体。功能,安全,性能等等测试
  • 验收测试:甲方或者客户来验收这个软件是不是它要的软件,协助验收

单元策略在开发阶段完成。面对繁多的分类方式,Google 有自己的命名:小型测试、中型测试和大型测试。

可以看到 Google 所谓小型测试就是单元测试,我们引入其定义。

单元测试的定义

单元测试是指对软件中的最小可测试单元进行检查和验证,是最低级别的测试活动。开发者编写的一小段代码,用于检验被测代码的一个很小的、很明确的功能是否正确。通常而言,一个单元测试是用于判断某个特定条件(或者场景)下某个特定函数的行为。

  • 验证代码与设计相符合;
  • 跟踪需求与设计的实现;
  • 发现设计和需求中存在的缺陷;
  • 发现在编码过程中引入的错误。

单元测试与其他测试的区别

单元测试与集成测试的区别

  • 测试对象不同:单元测试对象是实现了具体功能的程序单元;集成测试对象是概要设计规划中的模块及模块间的组合。
  • 测试方法不同:单元测试中的主要方法是基于代码的白盒测试;集成测试中主要使用基于功能的黑盒测试。
  • 测试时间不同:集成测试晚于单元测试。
  • 测试内容不同:单元测试主要是模块内程序的逻辑、功能、参数传递、变量引用、出错处理及需求和设计中具体要求方面的测试;集成测试主要验证各个接口、接口之间的数据传递关系,及模块组合后能否达到预期效果。

单元测试与系统测试的区别

  • 单元测试属于白盒测试,从开发者的角度出发,关注的是单元的具体实现、内部逻辑结构和数据流向;系统测试属于黑盒测试,从用户角度出发,证明系统已满足用户的需要。
  • 单元测试使问题及早暴露,便于定位解决,属于早期测试;系统测试是一种后期测试,定位错误比较困难。
  • 单元测试允许多个被测单元同时进行测试;系统测试时基于需求规格说明书。

单元测试的必要性

因为场景覆盖、逻辑不自闭甚至低级错误等诸多因素导致代码很难编写一次就正确执行,这就需要单元测试存在。而项目复杂度和代码量日益增长,手工回归测试时间越来越长,这就需要单元测试来兜底。

如果读者经历开发维护过没有单元测试的中型项目,很难同意代码很难不变成臭不可闻的 shit mountain:不敢轻易重构,添加功能小心翼翼避免触碰到不知道在哪里的隐匿逻辑引入问题。而测试人员更是叫苦连天,回归一轮下来时间久,线上问题层出不穷。

测试金字塔

一个健康、快速、可维护的测试组合应该是这样的:写许多小而快的单元测试,适当写一些更粗粒度的测试,写很少高层次的端到端测试。

大量单元测试作为金字塔基底,在此之上是一些集成测试,再往上是自动化相关测试。

  • 越靠近金字塔底部,测试组织起来越快,开展的成本越低
  • 越靠近金字塔顶部,测试组织起来越慢,开展的成本越高

来自微软的统计数据:bug 在单元测试阶段被发现,平均耗时3.25小时,如果漏到系统测试阶段,要花费11.5小时。

在开发阶段发现 bug,其解决成本远远比上线之后暴露问题要低得多得多。

代码的可测试性

截止目前为止我们都在推广形而上学的内容,从现在开始,我们以 dotnet 相关示例说明可测试性相关内容。要保证每个组件的正确性以及可以校验变化,其实是希望将代码的质量保障提前,保证每个组件在开发阶段能够测试;而想要每个组件能够测试,在设计过程中,就要保证每个模块是可以测试的,而这就是可测试性。

单元测试不仅用来测试代码功能,还可以用来测试代码设计,不好写单测的代码都是不好的代码。

好的测试容易写、可读、可靠、快速。我们在设计以及编写代码时,必须将可测试性纳入考量,在定义可测试性时不妨先看反模式

未决行为/非确定性

// BAD
public class PowerTimer
{
	public String GetMeridiem()
	{
		var time = DateTime.Now;
		if (time.Hour >= 0 && time.Hour < 12)
		{
			return "AM";
		}
		return "PM";
	}
}

public class PowerTimerTest
{
	[Fact]
	public void get_meridiem_before_12_return_am()
	{
		// HOW?
		// Assert.Equal(new PowerTimer().GetMeridiem(), "AM");
	}
}

DateTime.Now 本质上是一个隐藏的输入,在程序执行期间或测试运行之间可能会更改,对其调用将产生不同的结果。引入方法参数可以修复该 API:

public class PowerTimer
{
	public String GetMeridiem(DateTime time)
	{
		if (time.Hour >= 0 && time.Hour < 12)
		{
			return "AM";
		}
		return "PM";
	}
}

public class PowerTimerTest
{
	[Fact]
	public void get_meridiem_before_12_return_am()
	{
		var time = new DateTime(2021, 6, 15, 1, 0, 0);
		Assert.Equal(new PowerTimer().GetMeridiem(time), "AM");
	}
}

直接依赖于实现

// BAD
public class DepartmentService
{
	private CacheManager _cacheManager = new CacheManager();

	public List<Department> GetDepartmentList()
	{
		List<Department> result;
		if (_cacheManager.TryGet("department-list", out result))
		{
			return result;
		}
        // ...
	}
}


public class CacheManager
{
	public bool TryGet<T>(string key, out T value)
	{
        // ...

假设 CacheManager 直接去访问 redis 或 memcached 之类的缓存,就没办法进行单元测试了。解开紧密耦合的依赖,注入对象能修复该 API。

public class DepartmentService
{
	private CacheManager _cacheManager;
	
	public DepartmentService(CacheManager cacheManager)
	{
		_cacheManager = cacheManager;
	}

	public List<Department> GetDepartmentList()
	{
		List<Department> result;
		if (_cacheManager.TryGet("department-list", out result))
		{
			return result;
		}

		// ...
	}
}

额外地说:"依赖注入" 是广义概念,并不限于 Microsoft.Extensions.DependencyInjection、Autofat、Castle 之类框架和其使用。

全局变量/单例模式

像 C# 等高级语言中没有全局变量,但单例模式是存在的,它是全局变量的另一种形式。

// BAD
public class UserService
{
	public User CreateUser(string name)
	{
        var id = GlobalCounter.Instance.NextId();
		var user = new User(id, name);
        // ...
	}
}


public class GlobalCounter
{
    private static readonly GlobalCounter Instance = new GlobalCounter();
    
	public long NextId()
	{
        // ...

单例模式同样依赖于真实的依赖关系,并在组件之间引入了不必要的紧密耦合,但并不是说不能使用单例模式。注入对象能修复该 API。

public class UserService
{
    private readonly GlobalCounter _globalCounter;
    public UserService(GlobalCounter globalCounter) 
    {
        _globalCounter = globalCounter;
    }
    
	public User CreateUser(string name)
	{
        var id = _globalCounter.NextId();
		var user = new User(id, name);
        // ...
	}
}

静态方法/函数

静态方法是不确定性或副作用行为的另一个潜在来源。它们可以轻松引入紧密耦合,并使我们的代码不可测试。ASPNET MVC 中 HttpContext 是密封(sealed )类,完全没有可测试性。微软先后引入了 HttpContextBaseHttpContextWrapper 来补救,并最终在 ASPNET Core 中将其抛弃。

// BAD
public void GetPageTitle()
{
    if (HttpContext.Current.User.Identity.IsAuthenticated)
    {
        Page.Title = "Home page for " + HttpContext.User.Identity.Name;
    }
    else
    {
        Page.Title = "Home page for guest user.";
    }
}

当然并不是说不能使用静态方法/函数,静态方法/函数不该依赖于外部环境,系统时间,网络等。

// BAD
public static bool CheckNodejsInstalled()
{
    return Environment.GetEnvironmentVariable("PATH").Contains("nodejs", StringComparison.OrdinalIgnoreCase);
}

传递参数能修复该 API。

public static bool CheckNodejsInstalled(string path)
{
    return path != null && path.Contains("nodejs", StringComparison.OrdinalIgnoreCase);
}

复杂继承

如果父类需要 mock 某个依赖才能进行单元测试,那其派生类在编写单元测试的时候,都要 mock 这个依赖对象。理论上层次越深 mock 工作越多,其实这也是高耦合的一种体现,使得很难编写单元测试。

abstract class Issue
{
	public Issue(Content content)
	{
        // do stuff with content
	}
}

class RegularIssue : Issue
{
	public RegularIssue(Content content, Plan plan)
		: base(content)
	{
        // do stuff with plan
	}
}

class SignificantIssue : RegularIssue
{
	public SignificantIssue(Content content, Plan plan, Bug bug)
		: base(content, plan)
	{
         // do stuff with bug
	}
}

也许我们只想测试 SignificantIssue 的部分功能,但是构造其实例需要引入不相关的依赖,虽然你可能将其置空了事,但如果父类进行了很严谨的非空检查甚至是类型检查,测试恐怕并不是那么容易。

高耦合代码

耦合度高的代码很难找到单元测试的切入点,也很难写出高效的测试代码。单元测试像是花盆里的沙子,保证可测试的过程要求我们很好的拆分代码,它会降低土壤的粘度(耦合性)

私有方法

私有方法无法测试,如果希望被测试则应考虑设计的合理性。对于 dotnet 项目来说,InternalsVisibleTo 可以帮助我们测试内部类,后文会略有篇幅描述。

单元测试策略

如果进行单元测试,这里推荐自底向上或孤立的单元测试策略。

  • 自底向上的单元测试:先对最底层的基本单元进行测试,模拟调用该单元的单元做驱动模块。然后再对上面一层进行测试,用下面已被测试过的单元做桩模块。依此类推,直到测试完所有单元。
  • 孤立单元测试:不考虑每个单元与其它单元之间的关系,为每个单元设计桩模块或驱动模块。每个模块进行独立的单元测试。

这里引入了一些术语像"桩(stub)"之类,后文会有篇幅描述,可以先使用 mock/fake 作为替代理解。

Newtonsoft.Json 中最基本的对象是 JToken,其继承结构如下:

Newtonsoft.Json.Linq.JToken 
├── Newtonsoft.Json.Linq.JContainer 
│   ├── Newtonsoft.Json.Linq.JArray 
│   ├── Newtonsoft.Json.Linq.JConstructor 
│   ├── Newtonsoft.Json.Linq.JObject 
│   └── Newtonsoft.Json.Linq.JProperty 
└── Newtonsoft.Json.Linq.JValue 
    └── Newtonsoft.Json.Linq.JRaw 

UML 图更直观

该图作于 2013 年,仍适用于当前版本的 json.net,可见基设计之稳定。

Src/Newtonsoft.Json.Tests/Linq 中展示了相关的测试实现

  • JTokenTests.cs:最基本的测试,不依赖其他实现
  • JValueTests.cs:使用 JToken 测试 JValue 的方法,
  • JArrayTests.cs:使用 JValue 测试 JArray 的方法
  • JObjectTests.cs:使用 JValue 测试 JObject 的方法,少量使用 JProperty
  • JConstructorTests.cs:使用 JTokenJValue 测试 JConstructor 的方法
  • JRawTests.cs:使用 JToken 测试 JRaw 的方法

单元测试误区

现代开发框架做了很多工作使得组织项目变得容易,但不应深度开发框架中的高级特性。

如果脱离开发框架无法做单元测试,就说明代码已经不具备可测试性。大量借助开发框架进行单元测试,会蒙蔽团队的眼睛,让团队成员看不到代码边的有多糟糕。

部分参考

posted @ 2021-06-29 09:16  leoninew  阅读(1048)  评论(1编辑  收藏  举报