这将是一系列围绕单元测试的文章,在这些文章中,我将通过示例并探讨该技术的各个方面。 这是第一期。
与本文相关的代码可以在GitHub上找到 。 将来和过去的文章可以在The Librarian Archive中找到。
我将尝试对具有书籍和会员资格的Library
模块实施一些要求,并随着测试的进行而扩展我们以测试驱动风格(“ TDD”)编写的所有代码 。 我对过程有一些想法,展示一些重构,并给出一些使用IDE的提示。
本文的级别适用于希望扩展测试范围的初级开发人员。
那里有很多信息来描述什么是TDD或测试驱动开发 ,红绿重构周期等,因此在这里我不会过多地介绍细节。 有关更多背景信息,请参见最后的参考。
相反,就开始吧!
我们从带有以下build.gradle
文件的Gradle项目开始:
apply plugin: 'java'
repositories {
jcenter()
}
dependencies {
testCompile 'junit:junit:4.12'
}
这说明我们处于一个干净的Java项目中,可以在src/test/java
终止我们的测试,而在src/main/java
终止源,并且我们依赖于JUnit(我们选择的测试框架)。 我可以选择TestNG或Spock,但这是另一篇文章的主题。
微故事中的第一个故事
我们正在通过测试来驱动代码,并且正在根据需求来驱动测试 。 那就是我们要重复的循环。
如果我们处于敏捷项目中,那么需求可能会以用户故事的形式出现,例如:
作为图书馆员,
我希望喜欢书的人成为图书馆的成员
这样我以后可以借书给他们
我们可以在此处确定一些概念:图书馆,人员,(成为)成员,(借出)书籍。 让我们集中精力看一下我们的核心概念:图书馆。
因此,您可能很想潜入并创建例如Library
类和代码,但是我们不会这样做!
进行失败的测试
我们将从失败的测试开始。
package example;
import static org.junit.Assert.*;
import org.junit.Test;
public class LibraryTest {
@Test
public void test() {
fail("Not yet implemented");
}
}
一个名为test()
公共方法,带有JUnit的@Test
注释。 您可以在此处看到静态导入的方法fail()
这在我们运行IDE或通过Gradle运行LibraryTest
类时给我们带来了坚实的失败。
java.lang.AssertionError: Not yet implemented
at org.junit.Assert.fail(Assert.java:88)
at example.LibraryTest.test(LibraryTest.java:11)
意图透露名称
我们将把该方法重命名为一个更合适的测试名称, 该名称表明了我们第一个测试的意图 ,即测试成员应该能够注册 。
让我们shouldRegisterMembers()
。
package example;
import static org.junit.Assert.*;
import org.junit.Test;
public class LibraryTest {
@Test
public void shouldRegisterMembers() {
fail("Not yet implemented");
}
}
(是的,您可以运行它,但是它仍然会失败)
我没有任何要调用的代码
好吧 我们将改变它。
通过一系列执行良好的重构 (希望由您的IDE执行),我们将创建足够的代码来通过测试 。 这样,我们将创建生产代码; Library
类的代码尚不存在 。 现在的规则是:如果没有测试需要一段生产代码, 我们将不会编写它 。
我们需要一个Library
类来注册其成员。 所以我现在写下了我唯一需要的代码。
package example;
import static org.junit.Assert.*;
import org.junit.Test;
public class LibraryTest {
@Test
public void shouldRegisterMembers() {
// given
Library library = new Library();
}
}
如果您在IDE中,则会发出类似“无法将库解析为类型”之类的信号。 是的,这就是您友好的编译器是乐于助人的伙伴。 您需要通过创建类来解决此问题,或者让IDE为您创建它。
在例如Eclipse中,您可以选择一个称为“创建类库”的快速修复 。
使用IDE,卢克!
对于现代IDE而言,执行代码修改(例如创建缺少的类或方法)是不费吹灰之力的, 我强烈建议您始终将它们用于此类任务 。
我们新创建的Library
看起来像
package example;
public class Library {
}
我们需要编译测试类并运行测试。 并通过。
仅仅实例化一个new Library
测试(什么都不做)还没有增加价值。 这是必需的,因为我们需要根据用户故事(“注册成员”)创建逻辑。
有什么更好的方式来表示此方法调用: registerMember
? 我们对成员还不太了解,但是现在我给他或她起一个名字 -一个简单的String
传递给它以识别成员。
稍后,我需要与我的图书馆员交谈,以阐明我们希望成员拥有的所有属性 。
因此,我们正在注册一个成员,并提供示例值“ Ted”。 现在的代码将如下所示:
public class LibraryTest {
@Test
public void shouldRegisterMembers() {
// given
Library library = new Library();
// when
library.registerMember("Ted");
}
}
LibraryTest
再次不再编译 。 编译器会抱怨:
The method registerMember(String) is undefined for the type Library
创建缺少的方法
使用IDE在Library
类中创建此方法。 没什么可看的吗? 现在,我们将做一些新的事情: 向其中添加一些Javadoc,描述其功能 。
仍然不多,但是LibraryTest
再次编译 。
package example;
public class Library {
/**
* Registers a new member using provided name.
*
* @param name
* The name of the member
*/
public void registerMember(String name) {
}
}
测试也通过了 。
由于我们仍然不相信我们已经实现了逻辑(因为我们还没有真正实现任何东西),所以让我们以这样的方式设计事物:如果注册成员成功,那么库将为我们提供全新的,完整的信息。 Member
回来了。
我们按以下方式调整测试:让registerMember
方法返回成功创建的Member
,因此我们可以检查成员的名称是否等于我们提供的名称。
public class LibraryTest {
@Test
public void shouldRegisterMembers() {
// given
Library library = new Library();
// when
Member newMember = library.registerMember("Ted");
// then check for member's name to be same
}
}
没错,此刻的registerMember
方法返回void
: 如果我们想要一个Member
我们将不得不更改该方法的返回类型 ,以便测试可以再次编译。 为此,我们首先必须创建一个Member
类,因为它也不存在。
public class Member {
}
public class Library {
/**
* Registers a new member using provided name.
*
* @param name
* The name of the member
* @return
*/
public Member registerMember(String name) {
}
}
在测试的驱动下,我们是否真的可以继续更改并创建类似的东西?
是。
差不多好了。 如果我们要检查我们可以使用Hamcrest成员的名字匹配器equalTo
并且is
一个尚未将要国产 getName()
-这个名字“泰德”从与一个输入比较Member
。
package example;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.*;
import org.junit.Test;
public class LibraryTest {
@Test
public void shouldRegisterMembers() {
// given
Library library = new Library();
// when
Member newMember = library.registerMember("Ted");
// then
assertThat(newMember.getName(), is(equalTo("Ted")));
}
}
现在我们需要通过什么测试?
- 要进行编译 ,
Member
类将需要一个名为getName()
的吸气剂。 按照惯例,但是代码中也需要我们将其放入registerMember
,使name
成为最终属性(不可变,无设置器),我们将由构造方法对其进行初始化。 执行双重打击 ,并这样做:
public class Member {
private final String name;
public Member(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
- 创建一个
Member
并返回它。
public class Library {
/**
* Registers a new member using provided name.
*
* @param name
* The name of the member
* @return registered member
*/
public Member registerMember(String name) {
return new Member(name);
}
}
! 测试通过。
看来我们已经按照用户故事满足了我们的要求,
作为图书馆员,
我希望喜欢书的人成为图书馆的成员
这样我以后可以借书给他们
满足要求了吗?
作为一个小小的注解:我正在看这个故事,并且看到了“成员”,即复数形式。 由于我的测试仅测试一名成员的库,因此我正在努力加强工作,并测试更多成员。
测试的最小成员注册数量-我需要对支持“成员” (复数)的图书馆有足够的信心-是两个 ,对吧?
现在稍微修改了测试以包括Ted和Bob。
package example;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.*;
import org.junit.Test;
public class LibraryTest {
@Test
public void shouldRegisterMembers() {
// given
Library library = new Library();
// when
Member newMember1 = library.registerMember("Ted");
Member newMember2 = library.registerMember("Bob");
// then
assertThat(newMember1.getName(), is(equalTo("Ted")));
assertThat(newMember2.getName(), is(equalTo("Bob")));
}
}
测试通过。
第二个故事–更快
让我们实现第二个用户故事。 它是这样的:
作为会计师,
我希望一个人只能注册一次
这样我就不会在同一个人身上拥有多个会员资格
当您看时,用代码实现的第一个用户故事并不是什么大问题。 我们没有镀金的东西,我们没有任何未经测试的东西。 您知道进行失败测试,修复和重复的过程。
设计位
让我们创建一个名为shouldNotRegisterAgainWhenAlreadyMember
的新测试–聪明吧?
package example;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.*;
import org.junit.Test;
public class LibraryTest {
@Test
public void shouldRegisterMembers() { ... }
@Test
public void shouldNotRegisterAgainWhenAlreadyMember() {
// given
Library library = new Library();
library.registerMember("Ted");
// when we register with same name
library.registerMember("Ted");
// then we should see it fail somehow
}
}
这是进行一些设计的地方。我们如何让代码告诉我们已经注册了相同名称的成员? 如果通常不可能做到这一点,但是我们仍然必须处理这种情况,则可以使用Java的异常机制。
我们将创建一个AlreadyMemberException
方法registerMember()
可以抛出该异常来指示此异常事件。
public class LibraryTest {
@Test
public void shouldRegisterMembers() { ... }
@Test
public void shouldNotRegisterAgainWhenAlreadyMember() {
// given
Library library = new Library();
library.registerMember("Ted");
// when we register with same name
try {
library.registerMember("Ted");
fail("should not have registered Ted twice");
} catch (AlreadyMemberException e) {
// success!
}
}
}
怎么了?
- 作为测试设置的一部分(
//given
),该库以名称为“ Ted”的现有成员开始。 - 当告知图书馆(“不要询问”)以相同的名称“ Ted”注册另一个成员时,我们期望抛出
AlreadyMemberException
:Library
不应允许多个同名成员 - 如果
registerMember("Ted")
没有引发异常,我们将对fail()
测试fail()
- 可能会有一种更优雅的方式来期望会抛出某些异常,但是我们不想超越自己
目前测试失败-由于我们尚未更新Library
因此不会引发任何异常。
现在开始吧。
该代码现在应该以某种方式在内部跟踪成员,否则它将无法记住在两次调用之间注册的成员 。
最简单的解决方案(KISS)为此使用了Collections Framework中的数据结构。 任何Collection
(例如ArrayList
)都具有诸如contains
(检查存在性)和add
)之类的方法,以满足我们的所有需求:
- 检查会员
- 添加成员
我们的解决方案如下所示:
package example;
import java.util.ArrayList;
import java.util.Collection;
public class Library {
private final Collection members = new ArrayList<>();
/**
* Registers a new member using provided name.
*
* @param name
* The name of the member
* @return registered member
*/
public Member registerMember(String name) {
Member newMember = new Member(name);
if (members.contains(newMember)) {
throw new AlreadyMemberException();
}
members.add(newMember);
return newMember;
}
}
运行我们的测试方法,然后… 仍然会失败 。 WAT?
了解你的框架
没有人抛出异常 ,但是代码很简单,可以期望contains
和add
应该可以正常工作。 这是测试本身的缺陷吗?
不,当将诸如Member
对象放入Collection
,我们必须知道,我们希望Collection
检查是否相等 (而非身份 ),我们需要在Member
实现equals()
和hashCode()
。
您可以使用任何不错的IDE生成这些方法(或从辅助框架调用辅助方法来执行此操作),但是下面的代码(由Eclipse生成)就足够了:
package example;
public class Member {
private final String name;
public Member(String name) {
this.name = name;
}
public String getName() {
return name;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((name == null) ? 0 : name.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Member other = (Member) obj;
if (name == null) {
if (other.name != null)
return false;
} else if (!name.equals(other.name))
return false;
return true;
}
}
测试通过!
重构
现在我们已经有了一些测试范围,我们可以做到现在为止还没有做的事情: 重构 !
规则是:
- 让它起作用
- 变得更好(更快等)
- 使其可读(干燥,可维护等)
我们成功了。 通过重构过程-通常被称为“一系列保留小行为的转换”,我们可以解决剩下的两个问题:如果可能的话,使其“更好”并易于阅读。 而且这总是可能的:多年来,我遇到的陷阱是人们可以重构到无限远–知道何时停止通常很棘手。
这些只是示例,但我们可以…
简化实施
如果我们转换为一个Collection-type,它本身可以防止重复,那么我们可能可以使我们的存在检查更加简单,摆脱contains
并直接使用add
的返回值。
我们可能可以用HashSet
替换ArrayList
,如果成员集合不允许添加已经拥有的成员,则add
将返回false
。 看起来像:
package example;
import java.util.Collection;
import java.util.HashSet;
public class Library {
private final Collection members = new HashSet<>();
/**
* Registers a new member using provided name. [...]
*/
public Member registerMember(String name) {
Member newMember = new Member(name);
if (!members.add(newMember)) {
throw new AlreadyMemberException();
}
return newMember;
}
}
简化测试
哈,您认为只有实施需要工作吗? 不,测试也符合测试-修复-重构的每个迭代条件,可以完善。
例如,如果我们查看LibraryTest
中每个测试方法中相同的部分,则是new Library()
的实例化。
public class LibraryTest {
@Test
public void shouldRegisterMembers() {
// given
Library library = new Library();
...
}
@Test
public void shouldNotRegisterAgainWhenAlreadyMember() {
// given
Library library = new Library();
...
}
}
我们可以应用称为“ 将局部变量转换为字段”的重构,并在每次测试之前使用JUnit的@Before
注释初始化我们的字段。
public class LibraryTest {
private Library library;
@Before
public void setUp() {
library = new Library();
}
@Test
public void shouldRegisterMembers() {
// when
Member newMember1 = library.registerMember("Ted");
Member newMember2 = library.registerMember("Bob");
...
}
@Test
public void shouldNotRegisterAgainWhenAlreadyMember() {
// given
library.registerMember("Ted");
...
}
}
测试通过!
(您可能想知道我什么时候才能解决它:-))
最后但并非最不重要的一点是-有一种方法可以简化对JUnit的某些异常的ExpectedException
: ExpectedException
规则-确实摆脱了我们上一次测试的混乱。
package example;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
public class LibraryTest {
@Rule
public ExpectedException thrown = ExpectedException.none();
private Library library;
@Before
public void setUp() {
library = new Library();
}
@Test
public void shouldRegisterMembers() { ... }
@Test
public void shouldNotRegisterAgainWhenAlreadyMember() {
// given
library.registerMember("Ted");
// fail when we register with same name
thrown.expect(AlreadyMemberException.class);
library.registerMember("Ted");
}
}
测试通过!
结论
我同意许多年前James Shore在一篇文章(“ TDD如何影响设计”)中得出的一些结论:TDD可以导致更好的设计,TDD可以导致糟糕的设计。 TDD视角只是测试领域的众多视角之一,并且是任何人的工具带中值得欢迎的补充。 我想相信我们还没有创建任何未经测试的生产代码,但是我还没有运行任何代码覆盖率工具来验证是否覆盖了所有路径–但这不是现在的目标无论哪种方式
我希望本文能够大致了解TDD周期如何工作以及如何通过我们的测试来指导类的设计,并最终带来回归测试套件,这是一个很大的副作用。
参考文献
- https://github.com/tvinke/testing-tdd-intro 本文的示例代码在GitHub上
- https://zh.wikipedia.org/wiki/Test-driven_development 测试驱动的开发在Wikipedia上很好地介绍了TDD
- http://www.jamesshore.com/Blog/How-Does-TDD-Affect-Design.html TDD上的James Shore及其对设计的影响
- http://www.jamesshore.com/Blog/Lets-Play/ 让我们玩:测试驱动开发截屏视频系列,作者是James Shore,内容包括Java,测试驱动开发和进化设计。 它记录了一个真正的软件项目,疣和所有东西的开发。
- http://martinfowler.com/articles/is-tdd-dead/ 好吧? 我当然要包括这篇文章-但请自己考虑!
- http://refactoring.com/catalog/ 重构目录 Martin Fowler对他的书中描述的重构进行了很好的概述
- http://junit.org/junit4/ JUnit 4使用的测试框架的主页
- https://github.com/junit-team/junit4/wiki/Exception-testing JUnit中的异常测试。 不仅是
ExpectedException
规则,还包括“ expected”参数
翻译自: https://www.javacodegeeks.com/2016/06/librarian-introduction-test-driven-development.html