读书笔记 -- Junit 实战(3rd)Ch07 用 mock object 进行测试
8.1 mock object 简介
隔离测试:最大优点是能编写专门测试单一方法的测试代码,而不会受到被测方法调用某个对象所带来的副作用的影响。
mock object (mocks):非常适合测试与代码的其余部分隔离开的一部分代码。
mocks 与隔离测试的区别:mock 并不实现任何逻辑,只提供一些方法的空壳,让测试控制替代类的所有业务方法的行为。
8.2 用 mock object 进行单元测试
// Account 类 @Data @AllArgsConstructor public class Account { // 账户 ID private String accountId; // 余额 private long balance; public void debit(long amount) { this.balance -= amount; } public void credit(long amount) { this.balance += amount; } }
// AccountManager 用户管理 public interface AccountManager { Account findAccountForUser(String userId); void updateAccount(Account account); }
// AccountService public class AccountService { private AccountManager accountManager; // 设置一个 set(),将 将 accountManager 对象传过来。在 TestAccountService 中,将 mockAccountManager 传递过来 // 或者,声明一个构造器 public void setAccountManager(AccountManager accountManager) { this.accountManager = accountManager; } public void transfer(String senderId, String beneficiaryId, long amount) { Account sender = accountManager.findAccountForUser(senderId); Account beneficiary = accountManager.findAccountForUser(beneficiaryId); sender.debit(amount); beneficiary.credit(amount); this.accountManager.updateAccount(sender); this.accountManager.updateAccount(beneficiary); } }
思路:
1. 完全实现的方法中的 TestAccountService
public class TestAccountService { @Test public void testTransferOk() { Account senderAccount = new Account("1", 300); Account beneficiaryAccount = new Account("2", 100);
// 这里需要有个类来实现 接口 AccountManager,即 AccountManagerImpl,该对象可以 对多个 Account 进行管理,然后将该 对象 传递给 AccountService 进行使用进行转账
accountService.transfer("1", "2", 50); assertEquals(250, senderAccount.getBalance()); assertEquals(150, beneficiaryAccount.getBalance()); } }
实现:
1. Mock 一个 AccountManger,可以实现:1)可以存储多个 Account 对象,比较省力的结构是定义一个 Map 结构;2)实现 findAccountForUser() 方法
// MockAccountManager 类 public class MockAccountManager implements AccountManager { private Map<String, Account> accounts = new HashMap<>(); // 将多个 Account 对象存储在 map 结构中,方便 findAccountForUser() 查找 public void addAccount(String userId, Account account) { this.accounts.put(userId, account); } @Override // 该方法通过 userId 查找,返回一个 Account 对象。 public Account findAccountForUser(String userId) { return this.accounts.get(userId); } @Override public void updateAccount(Account account) { // do nothing,因为测试 transfer 时不需要处理该逻辑 } }
2. 实现 TestAccountService 类
public class TestAccountService { @Test public void testTransferOk() { Account senderAccount = new Account("1", 300); Account beneficiaryAccount = new Account("2", 100); MockAccountManager mockAccountManager = new MockAccountManager(); mockAccountManager.addAccount("1", senderAccount); mockAccountManager.addAccount("2", beneficiaryAccount); AccountService accountService = new AccountService(); accountService.setAccountManager(mockAccountManager); accountService.transfer("1", "2", 50); assertEquals(250, senderAccount.getBalance()); assertEquals(150, beneficiaryAccount.getBalance()); } }
8.4 模拟 HTTP 连接
// 原始的 WebClient public class WebClient { public String getContent(URL url) { // 创建 StringBuffer 对象,存储可以递增的字符串 StringBuffer content = new StringBuffer(); try { // 使用给定的URL对象打开一个连接,并得到了一个HttpURLConnection对象。然后它进行了类型转换,将得到的连接对象转换为HttpURLConnection类型 HttpURLConnection connection = (HttpURLConnection) url.openConnection(); // 设置一个网络连接可以读取输入流。 // 在网络编程中,当你想要从网络连接读取数据时,你需要设置这个连接可以输入数据。 // 在Java中,你可以使用 HttpURLConnection 或 URLConnection 类的 setDoInput(boolean doInput) 方法来 // 设置这个连接可以输入数据。doInput 参数为 true 表示允许从连接读取数据,为 false 则表示不允许。 // 这行代码通常在建立网络连接之前使用,以确保你可以从该连接读取数据。 connection.setDoInput(true); // 通过HttpURLConnection对象调用getInputStream()方法,得到一个输入流is,这个输入流用于读取从URL获取的数据 InputStream is = connection.getInputStream(); int count; // 循环持续读取输入流中的数据,直到没有数据可读(当is.read()返回-1时)。 // 每次读取一个字符,使用Character.toChars(count)将字符代码转换为字符,并追加到StringBuffer中 while (-1 != (count = is.read())) { // Character.toChars(count) 将一个Unicode码转换为对应的字符。例如,如果 count 是65,那么这个方法会返回字符 'A'。 // 再通过 new String(char value[]) 转换为字符串 content.append(new String(Character.toChars(count))); } } catch (IOException e) { return null; } return content.toString(); } }
思路:模拟一个 URL,其中 url.openConnection() 返回一个 mock HttpURLConnection。通过 MockHttpURLConnection 决定 getInputStream() 返回什么。
但是,URL 是一个 final 类,没有接口可用。
解决方案:创建一个 ConnectionFactory 接口,类实现 ConnectionFactory 接口的作用是从一个连接返回一个 InputStream,无论连接时什么(HTTP、TCP/IP 等)。该重构技术被称为 类工厂重构。
// 使用类工厂重构的 WebClient2 public class WebClient2 { public String getContent(ConnectionFactory connectionFactory) { String workingContent; StringBuffer content = new StringBuffer(); try (InputStream is = connectionFactory.getData()) { int count; while (-1 != (count = is.read())) { content.append(new String(Character.toChars(count))); } workingContent = content.toString(); } catch (Exception e) { workingContent = null; } return workingContent; } } // 对应的 ConnectionFactory 接口 public interface ConnectionFactory { InputStream getData() throws Exception; }
测试新的 WebClient2:
思路:需要 Mock 一个 ConnectionFactory,可以将想要产生的结果通过 setter() 传给 MockConnectionFactory 类
// MockConnectionFactory public class MockConnectionFactory implements ConnectionFactory { private InputStream inputStream; public void setData(InputStream stream) { this.inputStream = stream; } @Override public InputStream getData() throws Exception { return inputStream; } }
// TestWebClient public class TestWebClient { @Test public void testGetContentOk() { MockConnectionFactory mockConnectionFactory = new MockConnectionFactory(); mockConnectionFactory.setData(new ByteArrayInputStream("It works".getBytes())); WebClient2 client = new WebClient2(); String workingContent = client.getContent(mockConnectionFactory); assertEquals("It works", workingContent); } }
8.6 mock 框架
8.6.1 EasyMock
// pom.xml 添加依赖
<!-- EasyMock 依赖项,仅可以 mock 接口 --> <dependency> <groupId>org.easymock</groupId> <artifactId>easymock</artifactId> <version>5.1.0</version> </dependency>
<!-- EasyMock 扩展项,可为类和接口生成 mock object --> <dependency> <groupId>org.easymock</groupId> <artifactId>easymockclassextension</artifactId> <version>3.2</version> </dependency>
EasyMock 的几个重点:
- EasyMock 框架只能 mock 对象;
- 使用 EasyMock 有两种声明预期的方式:1)返回为 void 时,在模拟对象上调用;2)返回任何类型的对象时,使用 EasyMock API 的 expect 和 andReturn 方法;
- 完成对预期的定义时,调用 reply(),该方法将 mock 从记录预期被调用的方法的地方传递到测试的地方;
- @AfterEach 使用任何模拟对象调用 verify() 验证是否触发了声明的方法调用预期;
public class TestAccountServiceEasyMock { // 这里声明 AccountManager,核心原因是 EasyMock框架只能 mock 接口对象 private AccountManager mockAccountManager; @BeforeEach public void setUp() { // step1: 调用 createMock() 创建所需类的一个 mock mockAccountManager = createMock("mockAccountManager", AccountManager.class); } @Test public void testTransferOk() { // 创建两个 Account 对象 Account senderAccount = new Account("1", 300); Account beneficiaryAccount = new Account("2", 100); // step2: 声明 预期 的方式一:返回为 void,直接在对象上调用 mockAccountManager.updateAccount(senderAccount); mockAccountManager.updateAccount(beneficiaryAccount); // 声明预期的方式二:返回任何类型的对象时,使用 EasyMock API 的 expect 和 andReturn 方法 expect(mockAccountManager.findAccountForUser("1")).andReturn(senderAccount); expect(mockAccountManager.findAccountForUser("2")).andReturn(beneficiaryAccount); // step3: 完成对预期的定义时,调用 reply() replay(mockAccountManager); // 调用 transfer AccountService accountService = new AccountService(); accountService.setAccountManager(mockAccountManager); accountService.transfer("1", "2", 50); // 验证 assertEquals(250, senderAccount.getBalance()); assertEquals(150, beneficiaryAccount.getBalance()); } @AfterEach public void tearDown() { // @AfterEach 使用任何模拟对象调用 verify() 验证是否触发了声明的方法调用预期 verify(mockAccountManager); } }