手把手教你实战TDD | 京东云技术团队

1. 前言

领域驱动设计,测试驱动开发。

我们在《手把手教你落地DDD》一文中介绍了领域驱动设计(DDD)的落地实战,本文将对测试驱动开发(TDD)进行探讨,主要内容有:TDD基本理解、TDD常见误区、TDD技术选型,以及案例实战。希望通过本文,读者能够理解掌握TDD并将其应用于实际开发中。

2. TDD基本理解

测试驱动开发(TDD)是一种软件开发方法,要求开发者在编写代码之前先编写测试用例,然后编写代码来满足测试用例,最后运行测试用例来验证代码是否正确。测试驱动开发的基本流程如下:

2.1 第一步、编写测试用例

在编写代码之前,先根据需求编写测试用例,测试用例应该覆盖所有可能的情况,以确保代码的正确性。

这一步又称之为“红灯”,因为没有实现功能,此时测试用例执行会失败,在IDE里面执行时会报错,报错为红色。

2.2 第二步、运行测试用例

由于没有编写任何代码来满足这些测试用例,因此这些测试用例将会全部运行失败。

2.3 第三步、编写代码

编写代码以满足测试用例,在这个过程中,我们需要编写足够的代码使所有的测试用例通过。

这一步又称之为“绿灯”,在IDE里面执行成功时是绿色的,非常形象。

2.4 第四步、运行测试用例

编写代码完成之后,运行测试用例,确保全部用例都通过。如果有任何一个测试用例失败,就需要回到第三步,修改代码,直至所有的用例都通过。

2.5 第五步、重构代码

在确保测试用例全部通过之后,可以对代码进行重构,例如将重复的代码抽取成函数或类,消除冗余代码等。

重构的目的是提高代码的可读性、可维护性和可扩展性。重构不改变代码的功能,只是对代码进行优化,因此重构之后的代码必须依旧能通过测试用例。

2.6 第六步、运行测试用例

重构之后的代码,也必须保证通过全部的测试用例,否则需要修改至用例通过。

3. TDD常见的误区

3.1 误区一、单元测试就是TDD

单元测试是TDD的基础,但单元测试并不等同于TDD。

单元测试是一种测试方法,它旨在验证代码中的单个组件(例如类或方法)是否按预期工作。

TDD是一种软件开发方法,它强调在编写代码之前先编写测试用例(即单元测试用例),并通过不断运行测试用例来指导代码的设计和实现。TDD是基于单元测试的,TDD的编写的测试用例就是单元测试用例。

TDD还强调测试驱动开发过程中的重构阶段,在重构阶段优化代码结构和设计,以提高代码质量和可维护性。单元测试通常不包括重构阶段,因为它们主要关注单元组件的功能性验证。

3.2 误区二、误把集成测试当成单元测试

TDD在很多团队推不起来,甚至连单元测试都推不起来,归根到底是大家对TDD和单元测试的理解有误区。很多开发者在编写测试用例时,以为自己编写的是单元测试,但实际上写的却是集成测试的用例,原因就在于不理解单元测试和集成测试的区别。

单元测试是指对软件中的最小可测试单元进行检查和验证的过程,通常是对代码的单个函数或方法进行测试。单元测试的对象是代码中的最小可测试单元,通常是一个函数或方法。单元测试的范围通常局限于单个函数或方法,只关注该函数或方法对输入数据的处理和输出数据的正确性,不涉及到其他函数或方法的影响,也不考虑系统的整体功能。

集成测试是指将单元测试通过的模块组合起来进行测试,以验证它们在一起能否正常协作和运行。集成测试的对象是系统中的组件或模块,通常是多个已通过单元测试的模块组合起来进行测试。集成测试可以发现模块之间的兼容问题、数据一致性问题、系统性能问题等。

在实际开发中,许多开发者只对最顶层的方法写测试用例,例如直接对Controller方法编写测试用例,然后启动容器,读写外部数据库,图省事一股脑把Controller、Service、Dao全测了。 这实际上写的是集成测试的用例,这会造成:

  • 测试用例职责不单一

单元测试用例职责应该单一,即只是验证业务代码的执行逻辑,不确保与外部的集成,集成了外部服务或者中间件的测试用例,都应视为集成测试。

  • 测试用例粒度过大

只针对顶层的方法编写测试用例(集成测试),忽略了许多过程中的public方法,会导致单元测试覆盖率过低,代码质量得不到保障。

  • 测试用例执行太慢

由于需要依赖基础设施(连接数据库),会导致测试用例执行得很慢,如果单元测试不能很快执行完成,开发者往往会失去耐心,不会再继续投入到单元测试中。

可以说,执行慢是单元测试和TDD推不起来的非常大的原因。

结论:单元测试必须屏蔽基础设施(外部服务、中间件)的调用,且单元测试仅用于验证业务逻辑是否按预期执行。

判断自己写的用例是否是单元测试用例,方法很简单:只需要把开发者电脑的网络关掉,如果能正常在本地执行单元测试,那么基本写的就是单元测试,否则均为集成测试用例。

2.3 误区三、项目工期紧别写单元测试了

开发者在将代码提交测试时,我们往往要求先自测通过才能提测。那么,自测通过的依据是什么?我认为自测通过的依据是开发者编写的单元测试用例运行通过、且覆盖了所有本次开发相关的所有核心方法。

我们在需求排期时,可以将自测的时间考虑进去,为单元测试争取足够的时间。

越早的单元测试作用越大,我们可以及早发现代码中的错误和缺陷,并及时进行修复,从而提高代码的可靠性和质量,而不是等到提测之后再修复,此时修复的成本更高。

在项目工期紧迫的情况下,更应该坚持写单元测试,这不会影响项目进度。相反,它可以帮助我们提高代码的质量和可靠性,减少错误和缺陷的出现,从而避免了后期因为错误导致的额外成本和延误。

本文介绍了不少提交单元测试运行速度地方法,读者可以将之应用到实际项目中,减少单测对开发时间的影响。

2.4 误区四、代码完成后再补单元测试

任何时候写单元测试都是值得鼓励的,都能使我们从单元测试中受益。

代码完成后再写单元测试的做法会导致问题在开发过程中被忽略,并在后期被发现,从而增加了修复问题的成本和风险。

TDD要求先写测试用例再写代码,开发人员应该在编写代码前就开始编写相应的测试用例,并在每次修改代码后运行测试用例以确保代码的正确性。

2.5 误区五、对单元测试覆盖率的极端要求

有的团队要求单元测试覆盖率要100%,有的团队则对覆盖率没有要求。

理论上单元测试应该覆盖所有代码和所有的边界条件,在实际中我们还需要考虑投入产出比。

在TDD中,红灯阶段写的测试用例,会覆盖所有相关的public 的方法和边界条件;在重构阶段,某些执行逻辑被抽取为private方法,我们要求这些private方法中只执行操作不再进行边界判断,因此重构后产生的private方法我们不需要考虑其单元测试。

2.6 误区六、单元测试只需要运行一次

许多开发人员认为,单元测试只要运行通过,证明自己写的代码满足本次迭代需求就可以了,之后不需要再运行。

实际上,单元测试的生命周期时和项目代码相同的,单元测试不只是运行一次,其影响会持续到项目下线。

每一次上线,都应该全量执行一遍单元测试,确保从前的测试用例都能通过,本次需求开发的代码没有影响到以前的逻辑,这样做能避免很多线上的事故。

一些年代久远的系统,我们对内部逻辑不熟悉时,如何使变更范围可控?答案就是全量执行单元测试用例,假如从前的测试用例执行不通过了,也就意味着我们本次开发影响了线上的逻辑。老系统没有单元测试怎么办?补。幸运的是现在有不少自动生成单元测试的工具,读者可以自行研究。

4. TDD技术选型

4.1 单元测试框架

JUnit和TestNG都是非常优秀的Java单元测试框架,任选其中一个都可以完整实践TDD,本文采用JUnit 5。

4.2 模拟对象框架

在单元测试中,我们常常需要使用Mock进行模拟对象,以便模拟其行为,使得单元测试可以更容易地编写。

Mock框架有很多,例如MockitoPowerMock等,本文采用Mockito

4.3 测试覆盖率

本文采用Jacoco作为测试覆盖率检测工具。

Jacoco是一款Java代码覆盖率工具,它可以帮助开发人员在代码编写过程中监测测试用例的覆盖情况,以便更好地了解测试用例的质量和代码的可靠性。Jacoco可以在代码执行期间收集覆盖信息,同时还可以生成报告,以便开发人员能够更好地了解代码的测试覆盖率。

Jacoco还支持在Maven、Gradle等构建工具中使用。开发人员可以通过在pom.xml或build.gradle文件中添加Jacoco插件来集成。

4.4 测试报告

测试报告框架有许多,例如Allure,读者可自行研究学习。

5. TDD案例实战

5.1 奇怪的计算器

本案例我们将实现一个奇怪的计算器,通过这个案例完整实践TDD的几个步骤。

限于篇幅,Maven pom文件、测试报告生成等配置就不贴出来了,请读者自行到本案例代码tdd-example/tdd-example-01中查看。

本案例的代码地址为:

https://github.com/feiniaojin/tdd-example

5.1.1 第一次迭代

奇怪的计算器的需求如下:

输入:输入一个int类型的参数
处理逻辑:
	(1)入参大于0,计算其减1的值并返回;
	(2)入参等于0,直接返回0;
	(3)入参小于0,计算其加1的值并返回

接下来采用TDD进行开发。

  • 第一步、红灯

编写测试用例,实现上文的需求,注意有三个边界条件,要覆盖完整。

public class StrangeCalculatorTest {  
	
	private StrangeCalculator strangeCalculator;  
	  
	  
	@BeforeEach  
	public void setup() {  
		strangeCalculator = new StrangeCalculator();  
	}  
	  
	@Test  
	@DisplayName("入参大于0,将其减1并返回")  
	public void givenGreaterThan0() {  
		//大于0的入参  
		int input = 1;  
		int expected = 0;  
		//实际计算  
		int result = strangeCalculator.calculate(input);  
		//断言确认是否减1  
		Assertions.assertEquals(expected, result);  
	}  
	  
	@Test  
	@DisplayName("入参小于0,将其加1并返回")  
	public void givenLessThan0() {  
		//小于0的入参  
		int input = -1;  
		int expected = 0;  
		//实际计算  
		int result = strangeCalculator.calculate(input);  
		//断言确认是否减1  
		Assertions.assertEquals(expected, result);  
	}  
	  
	@Test  
	@DisplayName("入参等于0,直接返回")  
	public void givenEquals0() {  
		//等于0的入参  
		int input = 0;  
		int expected = 0;  
		  
		//实际计算  
		int result = strangeCalculator.calculate(input);  
		//断言确认是否等于0  
		Assertions.assertEquals(expected, result);  
	}  
}

此时StrangeCalculator类和calculate方法还没有创建,会IDE报红色提醒是正常的。

创建StrangeCalculator类和calculate方法,注意此时未实现业务逻辑,应当使测试用例不能通过,在此抛出一个UnsupportedOperationException异常。

public class StrangeCalculator {

	public int calculate(int input) {  
		//此时未实现业务逻辑,因此抛一个不支持操作的异常,以便使测试用例不通过
		throw new UnsupportedOperationException();  
	}  
}

运行所有的单元测试:

此时报告测试不通过:

  • 第二步、绿灯

首先实现givenGreaterThan0这个测试用例对应的逻辑:

public class StrangeCalculator {  
	public int calculate(int input) {  
		//大于0的逻辑  
		if (input > 0) {  
			return input - 1;  
		}  
		//未实现的边界依旧抛出UnsupportedOperationException异常
		throw new UnsupportedOperationException();  
	}  
}

注意,我们目前只实现了input>0的边界条件,其他的条件我们应该继续抛出异常,以便使其不通过。

运行单元测试,此时有3个测试用例,其中只有两个出错了。

继续实现givenLessThan0用例对应的逻辑:

public class StrangeCalculator {  
	public int calculate(int input) {  

		if (input > 0) {  
			//大于0的逻辑  
			return input - 1;  
		} else if (input < 0) {
			//小于0的逻辑  
			return input + 1;  
		}  
		//未实现的边界依旧抛出UnsupportedOperationException异常
		throw new UnsupportedOperationException();  
	}  
}

运行单元测试,此时有3个测试用例,其中有1个出错:

继续实现givenEquals0用例对应的逻辑:

public class StrangeCalculator {  
	public int calculate(int input) {  
		//大于0的逻辑  
		if (input > 0) {  
			return input - 1;  
		} else if (input < 0) {  
			return input + 1;  
		} else {  
			return 0;  
		}  
	}  
}

运行单元测试:此时3个测试用例都通过了:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OOH7lAO9-1686711097770)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c1e8c772ca7946bfba69b78babcdbecb~tplv-k3u1fbpfcp-zoom-1.image)]

此时,打开Jacoco的测试覆盖率报告(tdd-example的pom.xml文件中将报告生成的位置配置为target/jacoco-report),打开index.html

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mObbVS8z-1686711097772)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e3381f962ee54129ad44583ac0f9dcf3~tplv-k3u1fbpfcp-zoom-1.image)]

可以看到,calculate所有的边界条件都覆盖到了。

  • 第三步、重构

本案例calculate中只有简单的计算,在实际开发中,我们进行重构时,可以将具体的业务操作抽取为private方法,例如:

public class StrangeCalculator { 

	public int calculate(int input) {  
		//大于0的逻辑  
		if (input > 0) {  
			return doGivenGreaterThan0(input);  
		} else if (input < 0) {  
			return doGivenLessThan0(input);  
		} else {  
			return doGivenEquals0(input);  
		}  
	}  
	
	private int doGivenEquals0(int input) {  
		return 0;  
	}  
	
	private int doGivenLessThan0(int input) {  
		return input + 1;  
	}  
	
	private int doGivenGreaterThan0(int input) {  
		return input - 1;  
	}  
}

再次执行单元测试,测试通过。

查看Jacoco覆盖率的报告,可以看到每个边界条件都被覆盖到。

5.1.2 第二次迭代

奇怪的计算器第二次迭代的需求如下:

(1)针对大于0且小于100的input,不再计算其减1的值,而是计算其平方值;

第二个版本的需求对上一个迭代的边界条件做了调整,我们需要先根据本次迭代,整理出新的、完整的边界条件:

(1)针对大于0且小于100的input,计算其平方值;
(2)针对大于等于100的input,计算其减去1的值;
(3)针对小于0的input,计算其加1的值;
(4)针对等于0的input,返回0

此时,之前的测试用例的入参有可能已经不满足新的边界了,但是我们暂时先不管它,继续TDD的“红灯-绿灯-重构”的流程。

  • 第一步,红灯

StrangeCalculatorTest中编写新的单元测试用例,用来覆盖本次的两个边界条件。

@Test  
@DisplayName("入参大于0且小于100,计算其平方")  
public void givenGreaterThan0AndLessThan100() {  

	int input = 3;  
	int expected = 9;  
	//实际计算  
	int result = strangeCalculator.calculate(input);  
	//断言确认是否计算了平方  
	Assertions.assertEquals(expected, result);  
}  
  
@Test  
@DisplayName("入参大于等于100,计算其减1的值")  
public void givenGreaterThanOrEquals100() {  
	int input = 100;  
	int expected = 99;  
	//实际计算  
	int result = strangeCalculator.calculate(input);  
	//断言确认是否计算了平方  
	Assertions.assertEquals(expected, result);  
}

运行所有单元测试,可以看到有测试用例没有通过:

  • 第二步、绿灯

实现第二次迭代的业务逻辑:

public class StrangeCalculator {

	public int calculate(int input) {  
	  
		if (input >= 100) {  
			//第二次迭代时,大于等于100的区间还是走老逻辑  
			return doGivenGreaterThan0(input);  
		} else if (input > 0) {  
			//第二次迭代的业务逻辑  
			return input * input;  
		} else if (input < 0) {  
			return doGivenLessThan0(input);  
		} else {  
			return doGivenEquals0(input);  
		}  
	}  
	  
	private int doGivenEquals0(int input) {  
		return 0;  
	}  
	  
	private int doGivenLessThan0(int input) {  
		return input + 1;  
	}  
	  
	private int doGivenGreaterThan0(int input) {  
		return input - 1;  
	}  
}

执行所有的测试用例,此时第二次迭代的givenGreaterThan0AndLessThan100givenGreaterThanOrEquals100这两个用例都通过了,但是givenGreaterThan0却没有通过:

这是为什么呢?这是因为边界条件发生了改变,givenGreaterThan0用例中的参数input=1,对应的是0<input<100的边界条件,此时已经调整了,0<input<100需要计算input的平方,而不是input-1。

我们审查之前迭代的单元测试用例,可以看到givenGreaterThan0的边界已经被givenGreaterThan0AndLessThan100givenGreaterThanOrEquals100覆盖到了。

一方面givenGreaterThan0对应的业务逻辑改变了,一方面已经有其他测试用例覆盖了givenGreaterThan0的边界条件,因此,我们可以将givenGreaterThan0移除了。

@Test  
@DisplayName("入参大于0,将其减1并返回")  
public void givenGreaterThan0() {  
	int input = 1;  
	int expected = 0;  
	int result = strangeCalculator.calculate(input);  
	Assertions.assertEquals(expected, result);  
}

@Test  
@DisplayName("入参大于0且小于100,计算其平方")  
public void givenGreaterThan0AndLessThan100() {  
	//于0且小于100的入参  
	int input = 3;  
	int expected = 9;  
	//实际计算  
	int result = strangeCalculator.calculate(input);  
	//断言确认是否计算了平方  
	Assertions.assertEquals(expected, result);  
}  
  
@Test  
@DisplayName("入参大于等于100,计算其减1的值")  
public void givenGreaterThanOrEquals100() {  
	//于0且小于100的入参  
	int input = 100;  
	int expected = 99;  
	//实际计算  
	int result = strangeCalculator.calculate(input);  
	//断言确认是否计算了平方  
	Assertions.assertEquals(expected, result);  
}

givenGreaterThan0移除后,重新执行单元测试:

这次执行通过了,我们也将测试用例维护在最新的业务规则下。

  • 第三步、重构

测试用例通过后,我们便可以进行重构了。

首先,抽取0<input<100边界内的逻辑,形成私有方法;

其次,input>=0边界条件下的doGivenGreaterThan0方法,如今已经名不副实,因此重新命名为doGivenGreaterThanOrEquals100

重构后代码如下:

public class StrangeCalculator {  

	public int calculate(int input) {  
	  
		if (input >= 100) {  
			//第二次迭代时,大于等于100的区间还是走老逻辑  
			// return doGivenGreaterThan0(input);  
			return doGivenGreaterThanOrEquals100(input);  
		} else if (input > 0) {
			//第二次迭代的业务逻辑
			return doGivenGreaterThan0AndLessThan100(input);  
		} else if (input < 0) {  
			return doGivenLessThan0(input);  
		} else {  
			return doGivenEquals0(input);  
		}  
	}  
	  
	private int doGivenGreaterThan0AndLessThan100(int input) {  
		return input * input;  
	}  
	  
	private int doGivenEquals0(int input) {  
		return 0;  
	}  
	  
	private int doGivenGreaterThanOrEquals100(int input) {  
		return input + 1;  
	}  
	  
	private int doGivenGreaterThan100(int input) {  
		return input - 1;  
	}  
}

5.1.3 第三次迭代

第三次迭代以及之后的迭代,都按照第二次迭代的思路进行开发。

5.2 贫血模型三层架构的TDD实战

贫血三层架构的模型是贫血模型,因此只需要对ControllerServiceDao这三层进行分别探讨即可。

5.2.1 Dao层单元测试用例

严格地说,Dao层的测试属于集成测试,因为Dao层的SQL语句其实是写给数据库去执行的,只有真正连接数据库进行集成测试时,我们才能确认是否正常执行。

Dao层的测试,我们希望验证自己写的Mapper方法是否能正常操作,例如某个ResultMap漏了字段、某个#{}没有正常赋值。

我们引入内存数据库(如H2数据库),通过集成到应用中的内存数据库模拟外部数据库,确保了单元测试的独立性,也提高了Dao层单元测试的速度,也使我们可以提前做一些测试,尽量提前发现一些问题。

H2内存数据库的配置,详细可以到本文配套的项目案例tdd-example/tdd-example-02中查看,案例地址如下:

https://github.com/feiniaojin/tdd-example

以下是mybatis-generator逆向生成的mapper,我们把它作为Dao层单元测试的例子。一般来说逆向生成的mapper属于可信任代码,所有不会再进行测试,在此仅作案例。

Dao层Mapper的代码如下:

public interface CmsArticleMapper {  
	int deleteByPrimaryKey(Long id);  
	  
	int insert(CmsArticle record);  
	  
	CmsArticle selectByPrimaryKey(Long id);  
	  
	List<CmsArticle> selectAll();  
	  
	int updateByPrimaryKey(CmsArticle record);  
}

Dao层Mapper的测试代码如下:

@ExtendWith(SpringExtension.class)  
@SpringBootTest  
@AutoConfigureTestDatabase  
public class CmsArticleMapperTest {  
	  
	@Resource  
	private CmsArticleMapper mapper;  
	  
	@Test  
	public void testInsert() {  
		CmsArticle article = new CmsArticle();  
		article.setId(0L);  
		article.setArticleId("ABC123");  
		article.setContent("content");  
		article.setTitle("title");  
		article.setVersion(1L);  
		article.setModifiedTime(new Date());  
		article.setDeleted(0);  
		article.setPublishState(0);  
		int inserted = mapper.insert(article);  
		Assertions.assertEquals(1, inserted);  
	}  
	  
	@Test  
	public void testUpdateByPrimaryKey() {  
		CmsArticle article = new CmsArticle();  
		article.setId(1L);  
		article.setArticleId("ABC123");  
		article.setContent("content");  
		article.setTitle("title");  
		article.setVersion(1L);  
		article.setModifiedTime(new Date());  
		article.setDeleted(0);  
		article.setPublishState(0);  
		int updated = mapper.updateByPrimaryKey(article);  
		Assertions.assertEquals(1, updated);  
	}  
	  
	@Test  
	public void testSelectByPrimaryKey() {  
		CmsArticle article = mapper.selectByPrimaryKey(2L);  
		Assertions.assertNotNull(article);  
		Assertions.assertNotNull(article.getTitle());  
		Assertions.assertNotNull(article.getContent());  
	}  
}

5.2.2 Service层单元测试用例

重点关注的一层,为了确保用例执行的效率以及屏蔽基础设施调用,Service层所有对基础设施的调用都应该Mock掉。

Service层的代码如下:

@Service  
public class ArticleServiceImpl implements ArticleService {  
	  
	@Resource  
	private CmsArticleMapper mapper;  
	  
	@Resource  
	private IdServiceGateway idServiceGateway;  
	  
	@Override  
	public void createDraft(CreateDraftCmd cmd) {  
	  
		CmsArticle article = new CmsArticle();  
		article.setArticleId(idServiceGateway.nextId());  
		article.setContent(cmd.getContent());  
		article.setTitle(cmd.getTitle());  
		article.setPublishState(0);  
		article.setVersion(1L);  
		article.setCreatedTime(new Date());  
		article.setModifiedTime(new Date());  
		article.setDeleted(0);  
		mapper.insert(article);  
	}  
	  
	@Override  
	public CmsArticle getById(Long id) {  
		return mapper.selectByPrimaryKey(id);  
	}  
}

Service层的测试代码如下:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE,  
classes = {ArticleServiceImpl.class})  
@ExtendWith(SpringExtension.class)  
public class ArticleServiceImplTest {  
  
	@Resource  
	private ArticleService articleService;  
	  
	@MockBean  
	IdServiceGateway idServiceGateway;  
	  
	@MockBean  
	private CmsArticleMapper cmsArticleMapper;  
	  
	@Test  
	public void testCreateDraft() {  
	  
		Mockito.when(idServiceGateway.nextId()).thenReturn("123");  
		Mockito.when(cmsArticleMapper.insert(Mockito.any())).thenReturn(1);  
		  
		CreateDraftCmd createDraftCmd = new CreateDraftCmd();  
		createDraftCmd.setTitle("test-title");  
		createDraftCmd.setContent("test-content");  
		articleService.createDraft(createDraftCmd);  
		  
		Mockito.verify(idServiceGateway, Mockito.times(1)).nextId();  
		Mockito.verify(cmsArticleMapper, Mockito.times(1)).insert(Mockito.any());  
	}  
	  
	@Test  
	public void testGetById() {  
		CmsArticle article = new CmsArticle();  
		article.setId(1L);  
		article.setTitle("testGetById");  
		Mockito.when(cmsArticleMapper.selectByPrimaryKey(Mockito.any())).thenReturn(article);  
		  
		CmsArticle byId = articleService.getById(1L);  
		  
		Assertions.assertNotNull(byId);  
		Assertions.assertEquals(1L,byId.getId());  
		Assertions.assertEquals("testGetById",byId.getTitle());  
	}
}

通过Jacoco的覆盖率报告可以看到Service的逻辑都覆盖到了:

5.2.3 Controller层单元测试用例

非常薄的一层,按照预想是不涉及业务逻辑的,如果只涉及内外模型的转换,因此单元测试可忽略。如果实在想测一下,可以使用MockMvc

Controller的代码如下:

@RestController  
@RequestMapping("/article")  
public class ArticleController {  
	  
	@Resource  
	private ArticleService articleService;  
	  
	@RequestMapping("/createDraft")  
	public void createDraft(@RequestBody CreateDraftCmd cmd) {  
		articleService.createDraft(cmd);  
	}  
	  
	@RequestMapping("/get")  
	public CmsArticle get(Long id) {  
		CmsArticle article = articleService.getById(id);  
		return article;  
	}
}

Controller的测试代码如下:

@ExtendWith(SpringExtension.class)  
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK,  
classes = {ArticleController.class})  
@EnableWebMvc  
public class ArticleControllerTest {  
	  
	@Resource  
	WebApplicationContext webApplicationContext;  
	  
	MockMvc mockMvc;  
	  
	@MockBean  
	ArticleService articleService;  
	  
	//初始化mockmvc  
	@BeforeEach  
	void setUp() {  
		mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();  
	}  
	  
	@Test  
	void testCreateDraft() throws Exception {  
	  
		CreateDraftCmd cmd = new CreateDraftCmd();  
		cmd.setTitle("test-controller-title");  
		cmd.setContent("test-controller-content");  
		  
		ObjectMapper mapper = new ObjectMapper();  
		String valueAsString = mapper.writeValueAsString(cmd);  
		  
		Mockito.doNothing().when(articleService).createDraft(Mockito.any());  
		  
		mockMvc.perform(MockMvcRequestBuilders  
		//访问的URL和参数  
		.post("/article/createDraft")  
		.content(valueAsString)  
		.contentType(MediaType.APPLICATION_JSON))  
		//期望返回的状态码  
		.andExpect(MockMvcResultMatchers.status().isOk())  
		//输出请求和响应结果  
		.andDo(MockMvcResultHandlers.print()).andReturn();  
	}  
	  
	@Test  
	void testGet() throws Exception {  
	  
		CmsArticle article = new CmsArticle();  
		article.setId(1L);  
		article.setTitle("testGetById");  
		  
		Mockito.when(articleService.getById(Mockito.any())).thenReturn(article);  
		  
		mockMvc.perform(MockMvcRequestBuilders  
		//访问的URL和参数  
		.get("/article/get").param("id","1"))  
		//期望返回的状态码  
		.andExpect(MockMvcResultMatchers.status().isOk())
		.andExpect(MockMvcResultMatchers.jsonPath("$.id").value(1L))
		//输出请求和响应结果  
		.andDo(MockMvcResultHandlers.print()).andReturn();  
	}  
}

通过Jacoco的覆盖率报告可以看到Controller的逻辑都覆盖到了:

5.3 DDD下的TDD实战

DDD下的TDD实战,我们以《手把手教你落地DDD》一文的案例工程ddd-example-cms为例进行讲解,案例代码将实现在该项目中。

ddd-example-cms项目地址为:

https://github.com/feiniaojin/ddd-example-cms

DDD中各层的测试用例可以参考贫血模型,只做细微调整即可:

Application层的测试用例可以参考Service层单元测试用例进行编写;

Infrastructure层的测试用例代码可以参考Dao层单元测试用例进行编写;

User Interface层可以参考Controller层单元测试用例进行编写;

在此不多加赘述,详细实现可以到案例工程ddd-example-cms中查看。

5.3.1 实体的单元测试

实体的单元测试,要考虑两方面:创建实体必须覆盖其业务规则;业务操作必须复合其业务规则。

@Data  
public class ArticleEntity extends AbstractDomainMask {  
	  
	/**  
	* article业务主键  
	*/  
	private ArticleId articleId;  
	  
	/**  
	* 标题  
	*/  
	private ArticleTitle title;  
	  
	/**  
	* 内容  
	*/  
	private ArticleContent content;  
	  
	/**  
	* 发布状态,[0-待发布;1-已发布]  
	*/  
	private Integer publishState;  
	  
	/**  
	* 创建草稿  
	*/  
	public void createDraft() {  
		this.publishState = PublishState.TO_PUBLISH.getCode();  
	}  
	  
	/**  
	* 修改标题  
	*  
	* @param articleTitle  
	*/  
	public void modifyTitle(ArticleTitle articleTitle) {  
		this.title = articleTitle;  
	}  
	  
	/**  
	* 修改正文  
	*  
	* @param articleContent  
	*/  
	public void modifyContent(ArticleContent articleContent) {  
		this.content = articleContent;  
	}  
	
	/**  
	* 发布  
	*/
	public void publishArticle() {  
		this.publishState = PublishState.PUBLISHED.getCode();  
	}  
}

测试用例如下:

public class ArticleEntityTest {  
	  
	@Test  
	@DisplayName("创建草稿")  
	public void testCreateDraft() {  
		ArticleEntity entity = new ArticleEntity();  
		entity.setTitle(new ArticleTitle("title"));  
		entity.setContent(new ArticleContent("content12345677890"));  
		entity.createDraft();  
		Assertions.assertEquals(PublishState.TO_PUBLISH.getCode(), entity.getPublishState());  
	}  
	  
	@Test  
	@DisplayName("修改标题")  
	public void testModifyTitle() {  
		ArticleEntity entity = new ArticleEntity();  
		entity.setTitle(new ArticleTitle("title"));  
		entity.setContent(new ArticleContent("content12345677890"));  
		ArticleTitle articleTitle = new ArticleTitle("new-title");  
		entity.modifyTitle(articleTitle);  
		Assertions.assertEquals(articleTitle.getValue(), entity.getTitle().getValue());  
	}  
	  
	@Test  
	@DisplayName("修改正文")  
	public void testModifyContent() {  
		ArticleEntity entity = new ArticleEntity();  
		entity.setTitle(new ArticleTitle("title"));  
		entity.setContent(new ArticleContent("content12345677890"));  
		ArticleContent articleContent = new ArticleContent("new-content12345677890");  
		entity.modifyContent(articleContent);  
		Assertions.assertEquals(articleContent.getValue(), entity.getContent().getValue());  
	}  
	  
	@Test  
	@DisplayName("发布")  
	public void testPublishArticle() {  
		ArticleEntity entity = new ArticleEntity();  
		entity.setTitle(new ArticleTitle("title"));  
		entity.setContent(new ArticleContent("content12345677890"));  
		entity.publishArticle();  
		Assertions.assertEquals(PublishState.PUBLISHED.getCode(), entity.getPublishState());  
	}  
}

5.3.2 值对象的单元测试

值对象的单元测试,主要是必须覆盖其业务规则,以ArticleTitle这个值对象为例:

public class ArticleTitle implements ValueObject<String> {  
  
	private final String value;  
	  
	  
	public ArticleTitle(String value) {  
		this.check(value);  
		this.value = value;  
	}  
	  
	private void check(String value) {  
		Objects.requireNonNull(value, "标题不能为空");  
		if (value.length() > 64) {  
			throw new IllegalArgumentException("标题过长");  
		}  
	}  
	  
	@Override  
	public String getValue() {  
		return this.value;  
	}  
}

其单元测试为:

public class ArticleTitleTest {  
  
	@Test  
	@DisplayName("测试业务规则,ArticleTitle为空抛异常")  
	public void whenGivenNull() {  
		Assertions.assertThrows(NullPointerException.class, () -> {  
			new ArticleTitle(null);  
		});  
	}  
	  
	@Test  
	@DisplayName("测试业务规则,ArticleTitle值长度大于64抛异常")  
	public void whenGivenLengthGreaterThan64() {  
		Assertions.assertThrows(IllegalArgumentException.class, () -> {  
			new ArticleTitle("11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111");  
		});  
	}  
	  
	@Test  
	@DisplayName("测试业务规则,ArticleTitle小于等于64正常创建")  
	public void whenGivenLengthEquals64() {  
		ArticleTitle articleTitle = new ArticleTitle("1111111111111111111111111111111111111111111111111111111111111111"); 
		Assertions.assertEquals(64, articleTitle.getValue().length());  
	}  
}

5.3.3 Factory的单元测试

@Component  
public class ArticleDomainFactoryImpl implements ArticleFactory {  
  
@Override  
	public ArticleEntity newInstance(ArticleTitle title, ArticleContent content) {  
		ArticleEntity entity = new ArticleEntity();  
		entity.setTitle(title);  
		entity.setContent(content);  
		entity.setArticleId(new ArticleId(UUID.randomUUID().toString()));  
		entity.setPublishState(PublishState.TO_PUBLISH.getCode());  
		entity.setDeleted(0);  
		Date date = new Date();  
		entity.setCreatedTime(date);  
		entity.setModifiedTime(date);  
		return entity;  
	}  
}

我们将Factory实现在Application层,ArticleDomainFactoryImpl的测试用例 和Service层的测试用例是非常相似的。测试代码如下:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE,  
classes = {ArticleDomainFactoryImpl.class})  
@ExtendWith(SpringExtension.class)  
public class ArticleDomainFactoryImplTest {  
	  
	@Resource  
	private ArticleFactory articleFactory;  
	  
	@Test  
	@DisplayName("Factory创建新实体")  
	public void testNewInstance() {  
	  
		ArticleTitle articleTitle = new ArticleTitle("title");  
		ArticleContent articleContent = new ArticleContent("content1234567890");  
		  
		ArticleEntity instance = articleFactory.newInstance(articleTitle, articleContent);  
		
		// 创建新实体
		Assertions.assertNotNull(instance); 
		// 唯一标识正确赋值
		Assertions.assertNotNull(instance.getArticleId()); 
	}  
}

6. 总结

本文介绍了TDD的基本概念和实施方法,并提供了贫血模型三层架构和DDD下的TDD实战案例。我们要理解做出任何改变都会有一个艰难的开始,将现有的软件开发方法转变为TDD也不例外,但只要我们坚持下去,最终必定能从TDD中受益。

作者:京东物流 覃玉杰

来源:京东云开发者社区

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/28546.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

了解D-Galactopyranose pentaacetate,CAS号25878-60-8的性质和应用

​ 中文名称&#xff1a;1,2,3,4,6-D-葡萄糖五乙酸酯 英文名称&#xff1a;D-Galactopyranose pentaacetate 规格标准&#xff1a;1g、5g、10g CAS&#xff1a;25878-60-8 分子式&#xff1a;C16H22O11 分子量&#xff1a;390.34 熔点&#xff1a;113C 沸点&#xff1a;451C 密…

MATLAB文化算法

目录 文化算法 主要代码 Sphere AdjustCulture 结果 文化算法 基本概念&#xff1a;优化算法 | 详解文化算法&#xff08;附MATLAB代码&#xff09; - 知乎 不同于遗传算法只有种群进化空间&#xff0c;文化算法包含信念空间、种群空间两个进化空间&#xff0c;因此&#…

ceph分布式存储实战

ceph分布式存储实战 分布式存储系统简介 性能与优势对比 虚拟机安装ceph集群 ceph存储系统简介 分布式存储概述 ceph基础 高可用ceph分布式存储系统部署 部署ceph集群 节点管理 ceph使用基础及数据存储案例 PG状态、数据读写流程及存储池操作 mon服务器的高可用: # apt in…

KaiwuDB 受邀亮相 IOTE 2023 第十九届国际物联网展

5月17日&#xff0c;IOTE 2023 第十九届国际物联网展在上海拉开序幕&#xff0c;全球超过 350 家参展企业到场展示先进的物联网技术和产品&#xff0c;行业专家、领军企业代表等人物齐聚一堂&#xff0c;共话 IoT 未来趋势。KaiwuDB 受邀亮相参展并就《工业物联网产业数字化转型…

CRF条件随机场的原理、例子、公式推导和应用

转子&#xff1a;https://zhuanlan.zhihu.com/p/148813079 条件随机场&#xff08;Conditional Random Field&#xff0c;CRF&#xff09;是自然语言处理的基础模型&#xff0c;广泛应用于中文分词、命名实体识别、词性标注等标注场景。 条件随机场CRF与深度学习结合&#xf…

如何使用Github搭建个人博客

介绍 在本文中&#xff0c;我将介绍如何使用GitHub搭建个人博客&#xff08;免费&#xff09;。GitHub是一个功能强大的版本控制和协作平台&#xff0c;它也可以用来托管和发布静态网页。通过将你的个人博客托管在GitHub上&#xff0c;你可以享受到版本控制的好处&#xff0c;…

抖音自动生成视频、字幕、自动上传发布

dy-auto ✨ 抖音自动生成视频、字幕、自动上传发布✨ 项目地址 点击进入https://github.com/Richard0403/dy-auto 录屏效果 https://github.com/Richard0403/dy-auto/assets/14147304/21400a42-9296-4956-9517-ced8d8bf4737 技术架构 名称功能ffmpeg处理视频的生成&…

大模型LLM领域,有哪些可以作为学术研究方向?

清湛人工智能研究院 2023-05-31 09:23 发表于江苏 编者&#xff1a;本文转载了清华大学计算机系刘知远教授对大模型的一些思索&#xff0c;以飨读者。 刘知远 CCF 高级会员&#xff0c;CCCF 前编委。清华大学计算机系副教授、博士生导师。已在ACL、IJCAI、AAAI等人工智能领域…

【sentinel】Sentinel规则的持久化

Sentinel规则的推送有下面三种模式: 推送模式说明优点缺点原始模式API将规则推送至客户端并直接更新到内存中简单&#xff0c;无任何依赖不保证一致性&#xff1b;规则保存在内存中&#xff0c;重启即消失。严重不建议用于生产环境Pull模式扩展写数据源&#xff08;WritableDa…

初学Nginx要掌握哪些概念

文章目录 为什么要使用Nginx&#xff1f;什么是Nginx&#xff1f;Nginx的作用&#xff1f;反向代理负载均衡动静分离 为什么要使用Nginx&#xff1f; 小公司项目刚刚上线的时候&#xff0c;并发量小&#xff0c;用户使用的少&#xff0c;所以在低并发的情况下&#xff0c;一个…

【链表复习】C++ 链表复习及题目解析 (2)

目录 牛客 CM11 链表分割 牛客 OR36 之链表的回文结构 Leetcode 160. 相交链表 LeetCode 141. 环形链表 LeetCode 138. 复制带随机指针的链表 本文继续延续前文&#xff0c;为大家带来几道经典的链表中等难度的题目。 牛客 CM11 链表分割 现有一链表的头指针 ListNode* p…

GUT|IF30+的联合分析文章:宏基因加代谢组

● 代谢组学是基于LC-MS/MS液质联用技术对生物样本中的小分子代谢物进行定性和相对定量分析&#xff1b; ● 宏基因组-代谢组的联合分析可以用来解释差异菌群与差异代谢物的关联性&#xff1b; ● 从而帮助建立微生物-代谢物-表型之间的逻辑关系。 凌恩生物的宏基因组学引入了…

JDK21要来了,并发编程更加丝滑了

大家好&#xff0c;我是风筝&#xff0c;公众号「古时的风筝」&#xff0c;专注于 Java技术 及周边生态。 我的个人网站&#xff1a;古时的风筝 目前 Java 的最新稳定版是 JDK 20&#xff0c;但这是个过渡版&#xff0c;JDK21就是 LTS 版的了&#xff0c;也快要发布了&#xff…

经典文献阅读之--A Review of Motion Planning(轨迹规划回顾)

0. 简介 对于自动驾驶以及机器人而言&#xff0c;除了SLAM以外&#xff0c;另一个比较重要的部分就是轨迹规划了。而最近作者看到了几篇比较好的文章&#xff0c;分别为《A Review of Motion Planning Techniques for Automated Vehicle》、《A review of motion planning alg…

Python中处理无效数据的详细教程(附案例实战)

&#x1f935;‍♂️ 个人主页&#xff1a;艾派森的个人主页 ✍&#x1f3fb;作者简介&#xff1a;Python学习者 &#x1f40b; 希望大家多多支持&#xff0c;我们一起进步&#xff01;&#x1f604; 如果文章对你有帮助的话&#xff0c; 欢迎评论 &#x1f4ac;点赞&#x1f4…

IDEA全局设置JDK、Maven、编码格式

本机已安装JDK版本&#xff1a; 本机已安装Maven版本&#xff1a; 一、IDEA设置全局JDK设置 File---->New Projects Settings---->Structure for New Projects... 先将本地安装的JDK添加到SDK 将项目SDK设置为刚刚添加的本地JDK版本 File---->New Projects Settings-…

8分钟让你完全掌握代理IP基础知识和实际应用

概念 代理IP可以理解为一个中转服务器&#xff0c;将用户和目标服务器之间的请求和响应进行转发和代理。使用代理IP的主要目的是隐藏用户的真实IP地址、访问被限制的内容、提高网络连接速度和保护用户隐私。 目录 概念 一、代理IP的工作原理 二、代理IP的类型 三、为什么…

Docker安装ClickHouse22.6.9.11并与SpringBoot、MyBatisPlus集成

背景 上一篇文章CentOS6.10上离线安装ClickHouse19.9.5.36并修改默认数据存储目录记录了在旧版的操作系统上直接安装低版本 ClickHouse &#xff08;脱胎于俄罗斯头号搜索引擎的技术&#xff09;的过程&#xff0c;开启远程访问并配置密码&#xff1b; 其实通过 Docker 运行 …

ESXi 7.0 U3m Cisco (思科) 定制版 OEM Custom Installer CD

VMware ESXi 7.0 Update 3m - 领先的裸机 Hypervisor (All OEM Customized Installer CDs) ESXi 7.0 U3m Standard (标准版) ESXi 7.0 U3m Dell (戴尔) 定制版 OEM Custom Installer CD ESXi 7.0 U3m HPE (慧与) 定制版 OEM Custom Installer CD ESXi 7.0 U3m Lenovo (联想) 定…

6个ChatGPT4的最佳用途

文章目录 ChatGPT 4’s Current Limitations ChatGPT 4 的当前限制1. Crafting Complex Prompts 制作复杂的提示2. Logic Problems 逻辑问题3. Verifying GPT 3.5 Text 验证 GPT 3.5 文本4. Complex Coding 复杂编码5.Nuanced Text Transformation 细微的文本转换6. Complex Kn…