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

news2024/12/25 9:12:24

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中受益。

作者:京东物流 覃玉杰

来源:京东云开发者社区

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

depcheck检查缺失的或者位使用的依赖

depcheck它可以帮助我们找出问题&#xff0c;在 package.json 中&#xff0c;每个依赖包如何被使用、哪些依赖包没有用处、哪些依赖包缺失。它是解决前端项目中依赖包清理问题的一个常用工具 depcheck官方文档地址 Github&#xff1a;https://github.com/depcheck/depcheck 1…

笔记本触摸板没反应?1分钟,快速解决!

案例&#xff1a;在使用笔记本电脑时&#xff0c;我喜欢使用触摸板进行一些电脑上的操作。但是最近我的触摸板突然没反应&#xff0c;不能使用。有小伙伴知道这是什么原因吗&#xff1f;该如何解决呀&#xff1f; 笔记本电脑已经成为我们日常生活和工作中不可或缺的工具。然而…

光传感芯片产品应用领域解析

光传感产品主要应用于穿戴心率等健康检测、安防环境光监测、智能家居环境光感测、智慧电子产品自动控制、工业自动控制及安全检查、控制。 WH光感材料特点&#xff1a; 1、双波普独立通道&#xff0c;独立控制 2、波谱响应波长可客制化定制&#xff1a; —环境光红蓝绿、光距感…

企业邀请媒体报道活动,邀请本地媒体好,还是全国性的媒体好

传媒如春雨&#xff0c;润物细无声&#xff0c;大家好&#xff0c;我是51媒体网胡老师。 企业做活动在制定媒体策略&#xff0c;媒体传播规划的时候&#xff0c;往往不知道改如何选择&#xff0c;今天胡老师就来分享下本地媒体和全国性媒体的特点&#xff0c;帮助大家更好的制…

SpringCloud搭建Eureka服务注册中心(六)

前面说过eureka是c/s模式的 server服务端就是服务注册中心&#xff0c;其他的都是client客户端&#xff0c;服务端用来管理所有服务&#xff0c;客户端通过注册中心&#xff0c;来调用具体的服务&#xff1b; 我们先来搭建下服务端&#xff0c;也就是服务注册中心&#xff1b…

uniapp小程序订阅消息推送+Thinkphp5后端代码教程示例

记录一下通过uniapp开发小程序消息推送的实例&#xff0c;配合后端tp推送&#xff0c;之前写的项目是微信小程序而且后端是原生php&#xff0c;这次通过项目记录一下 目录 回顾access_token获取规则以及思路 第一步&#xff1a;设计前端触发订阅事件第二步&#xff1a;设计将to…

1140道Java常见面试题及详细答案

最近感慨面试难的人越来越多了&#xff0c;一方面是市场环境&#xff0c;更重要的一方面是企业对 Java 的人才要求越来越高了。 基本上这样感慨的分为两类人&#xff1a; 第一&#xff0c;虽然挂着 3、5 年经验&#xff0c;但肚子里货少&#xff0c;也没啥拿得出手的项目&#…

OPNET出现“Packet pointer references unowned packet(<pk_id>)”错误的解决办法

在使用 OPNET Modeler 软件时&#xff0c;会遇到很多奇奇怪怪的报错&#xff0c;今天要介绍的报错内容如下。 Packet pointer references unowned packet(<pk_id>). 程序中断的原因截图如下图所示。 由上图可以看到&#xff0c;引发错误的 OPNET 核心函数是 op_pk_send(…

快速幂应用之剪绳子问题

有这样一类问题&#xff0c;给你一个长度为n的绳子&#xff0c;要求你可以剪切任意次数&#xff0c;分为任意段&#xff0c;使得这些子段长度的乘积最大。我们把这类问题暂时先称为剪绳子&#xff0c;这种问题的解法也很简单&#xff0c;通过数学证明可以得出&#xff0c;我们优…

​Java容器的继承关系​

Java容器的继承关系 Collection接口 Collection接口中所定义的方法 int size(); boolean isEmpty(); void clear(); boolean contains(Object element);//是否包含某个对象 boolean add(Object element); Iterator iterator(); boolean containsAll(Collection c);//是否包含另…

MybatisPlus 实现数据拦截

基于配置文件实现&#xff08;关键key存储在配置文件&#xff0c;通过读取配置文件来实现动态拼接sql&#xff09; 1、创建注解类 UserDataPermission(id"app") 注&#xff1a;id用以区分是小程序还是应用程序 注解加的位置&#xff1a; 2、配置枚举类配置文件 E…

学了Python后还用学R语言吗?

学习R语言是否有必要取决于你的具体需求和背景。虽然R语言和Python都是数据科学领域广泛使用的编程语言&#xff0c;但它们之间还是存在一些差异。 如果你主要从事数据分析、统计建模或者数据可视化等工作&#xff0c;那么学习R语言可能更为适合。R语言在数据处理和统计分析方…

【gcc, cmake, eigen, opencv,ubuntu】四.opencv安装和使用,获取opencv matiax 的指针

文章目录 ubuntu系统安装opencv1.下载opencv和opencv_contrib2.安装指导3.Linux 下 fatal error: opencv2/opencv.hpp: 没有那个文件或目录4.g 和cmake 编译使用opencv的程序5.opencv,eigen速度比较6.opencv常用类型符号7.获取opencv matiax 的指针 ubuntu系统安装opencv 1.下…

Java实训日志03

文章目录 八、项目开发实现步骤&#xff08;五&#xff09;创建数据库连接管理类1、创建数据库实用工具包2、创建数据库连接管理类&#xff08;1&#xff09;定义数据库连接属性常量&#xff08;2&#xff09;创建私有化构造方法&#xff08;3&#xff09;编写获取数据库连接静…

关于C++数组名和指针的一些思考

在学习指针数组与数组指针一节时&#xff0c;了解到数组名其实是指向数组收个元素的指针。如下面代码所示 int main() {int a[5] {1, 2, 3, 4, 5};cout << "*a:" << *a << endl;cout << "*(a 1):" << *(a 1) << e…

注解开发bean

注解开发定义bean 使用component定义bean Component("bookDao") public class BookDaoImpl implements BookDao{} 核心配置文件中通过组件扫描加载bean <context:component-scan base-package"com.tsj"/> Spring提供Component注解的三个衍生注解…

科研人必看 | 学术期刊论文作者署名新规

【SciencePub学术干货】在期刊上发表学术论文&#xff0c;是研究人员发布和传播学术研究成果的重要方式之一。学术期刊论文文献各项著录内容中&#xff0c;作者署名是最主要的组成部分之一。 随着经济的发展和社会的进步&#xff0c;人们面临的科学问题和社会问题日趋复杂&…

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

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

基于SpringBoot+vue的网上图书商城系统设计和实现

博主介绍&#xff1a; 大家好&#xff0c;我是一名在Java圈混迹十余年的程序员&#xff0c;精通Java编程语言&#xff0c;同时也熟练掌握微信小程序、Python和Android等技术&#xff0c;能够为大家提供全方位的技术支持和交流。 我擅长在JavaWeb、SSH、SSM、SpringBoot等框架下…

通用方案px2rem 处理 内联样式、element-ui表头折行、label折行、表单项换行异常

通用方案-处理element-ui 表单项label折行、换行异常&#xff0c;表头折行问题 背景简介&#xff1a;在实际的生产环境中&#xff0c;客户用到的屏幕大大小小分辨率各有异同&#xff0c;但是为了布局的统一和美观&#xff0c;我们采用了postcss-px2rem插件对element-ui进行响应…