读书笔记 -- Junit 实战(3rd)Ch06 用 Stub 进行粗粒度测试
本章将研究 JUnit5 测试依赖于外部资源的应用程序。外部资源包含: HTTP 服务器、数据库服务器 和 物理设备等。
几种依赖外部资源的情况处理:
- 1. 依赖特定运行环境:建立真正的环境,但并不总是可行;
- 2. 通过 HTTP 依赖另一个 Web 服务:模拟该服务器;
- 3. 依赖另一部分未完成的应用程序:用一个类似行为的替代对象替换缺失的部分;
替代对象有2种策略:
1. stub:
- 当编写 stub 时,一开始就有一个预先确定的行为;
- stub 代码是在测试的外部编写的,并且总是有固定的行为。其方法通常会返回硬编码的值;
- 使用 stub 进行测试的模式:初始化 stub > 运行测试 > 验证断言;
2. mock object:
- 预先没有确定的行为;
- 在运行测试时,有效使用 mock 前,应在 mock 上设置预期。可以运行不同的测试,可以重新初始化一个 mock 并对其设置不同的预期;
- 使用 mock object 进行测试的模式:初始化 mock > 设置预期 > 运行测试 > 验证断言;
7.1 stub 简介
stub:一种机制。用来模拟真实代码或尚未完成的代码所产生的行为。通常不会改变所测试的代码,是适应代码以提供无缝集成。
stub 是一段代码,在运行期间使用插入的 stub 来替代真实的代码,以便将其调用者与真正的实现隔离。其目的是用一个简单行为替换一个复杂行为,从而可以测试真实代码的一部分。
使用到 stub 的情况:
- 不能修改一个现有系统,因为太复杂,容易崩溃;
- 依赖一个无法控制的环境;
- 替换一个成熟的外部系统,如文件系统、到服务器的连接或数据库;
- 运行粗粒度的测试,如不同子系统的集成测试;
使用 mock object 的情况:
- 需要细粒度的测试来提供精确的信息,如查找失败的原因;
- 单独测试一小部分代码;
stub 的缺点:
- 编码复杂,且自身也需要调试;
- 因为复杂,很难维护;
- 并不适合细粒度的单元测试;
- 每种情况都需要一种不同的 stub 策略;
7.3 用 stub 替换服务器资源
// 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(); // 创建了一个字节数组buffer,用于临时存储从输入流中读取的数据。 byte[] buffer = new byte[2048]; int count; // 循环持续读取输入流中的数据,直到没有数据可读(当is.read(buffer)返回-1时)。每次读取的数据量存储在变量count中。读取的数据被转换为字符串并追加到content中 // ** 通过查看 is.read() 的源码,其返回值就是 读取的数据量 while (-1 != (count = is.read(buffer))) { // String(byte[] bytes, int offset, int length) /** * 将从输入流中读取的字节数据转换为字符串,并将其追加到StringBuffer对象content中。 * 具体来说,new String(buffer, 0, count)创建了一个新的字符串对象,该对象是由buffer数组中的字节数据转换而来的。 * buffer数组中的字节数据从索引0开始,一直到索引count-1,因为count表示实际读取的字节数据的长度。 * 通过调用StringBuffer对象的append()方法,这个新的字符串被追加到content中,这样在循环结束后,content中就包含了 * 从输入流中读取的所有数据。 */ content.append(new String(buffer, 0, count)); } } catch (IOException e) { throw new RuntimeException(e); } return content.toString(); } }
解决方案一:stub 一个 web 服务器。缺点:很复杂。
public class TestWebClient { private WebClient client = new WebClient(); @BeforeAll // 替换 服务器环境的方式:嵌入式 Jetty 环境(创建 Server -> 设置上下文 Context -> 设置处理器 handler -> 启动服务器) static void setUp() throws Exception { // 创建了一个新的Server对象,并初始化它监听8081端口 Server server = new Server(8081); // 创建了一个新的Context对象,并将其关联到服务器和一个路径(“/testGetContentOk”) // 在Servlet中,Context是一个对象的容器,它包含了关于Servlet引擎的配置信息 // public Context(HandlerContainer parent, String contextPath) Context contentOkContext = new Context(server, "/testGetContentOk"); // 为上一步创建的Context对象设置一个处理器。处理器用于处理来自客户端的请求。这里的处理器是TestGetContentOkHandler的一个新实例 contentOkContext.setHandler(new TestGetContentOkHandler()); Context contentErrorContext = new Context(server, "/testGetContentError"); contentErrorContext.setHandler(new TestGetContentServerErrorHandler()); Context contentNotFoundContext = new Context(server, "/testGetContentNotFound"); contentNotFoundContext.setHandler(new TestGetContentNotFoundHandler()); // 在JVM关闭时会发送一个关闭信号给服务器,要求其停止所有的工作。设置为true表示服务器将在JVM关闭时立即停止所有的工作 server.setStopAtShutdown(true); // 启动服务器,开始监听指定的端口并等待客户端的请求 server.start(); } @AfterAll static void tearDown() { // 没有逻辑实现,是因为已经通过编程,使服务器关闭时停止。当 JVM 关闭时,服务器实例显式地停止(server.setStopAtShutdown(true))。 // empty } @Test public void testGetContentOk() throws MalformedURLException { String workingContent = client.getContent(new URL("http://localhost:8081/testGetContentOk")); assertEquals("It works", workingContent); } // 该类被上面的 static setUp() 引用,所以该类被声明为 static private static class TestGetContentOkHandler extends AbstractHandler { @Override public void handle(String target, HttpServletRequest request, HttpServletResponse response, int dispatch) throws IOException { // 从HttpServletResponse对象中获取一个输出流,并将其赋值给变量out。这个输出流可以用来向客户端发送HTTP响应的正文内容 OutputStream out = response.getOutputStream(); // 创建了一个新的ByteArrayISO8859Writer对象,并将其赋值给变量writer。这个类可以用来将字符串转换为字节数组,并支持ISO 8859-1编码 ByteArrayISO8859Writer writer = new ByteArrayISO8859Writer(); // 将字符串"It works"写入到上一步创建的ByteArrayISO8859Writer对象中 writer.write("It works"); // 清空缓冲区,确保所有数据都被写出 writer.flush(); // 设置HTTP响应头的CONTENT_LENGTH为writer.size(),也就是刚才写入的字符串的字节数。这告诉客户端接收到多少字节的数据 // setIntHeader(String name, int value). name -- name of the header response.setIntHeader(HttpHeaders.CONTENT_LENGTH, writer.size()); // 将writer缓冲区中的数据写入到上一步获取的输出流中,也就是发送给客户端 writer.writeTo(out); // 再次调用flush()方法,以确保所有数据都被完全发送到客户端 out.flush(); } } /** * Handler to handle bad requests to the server */ private static class TestGetContentServerErrorHandler extends AbstractHandler { @Override public void handle(String target, HttpServletRequest request, HttpServletResponse response, int dispatch) throws IOException { response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE); } } /** * Handler to handle requests that request unavailable content. */ private static class TestGetContentNotFoundHandler extends AbstractHandler { @Override public void handle(String target, HttpServletRequest request, HttpServletResponse response, int dispatch) throws IOException { response.sendError(HttpServletResponse.SC_NOT_FOUND); } } }
解决方案二(稍微轻量):替换 Http 连接。
思路:得益于 Java 的 URL 和 HttpURLConnection 类,我们可以插入自定义的 协议处理器 来处理任何类型的通信协议,可以使任何对 HttpURLConnection 类的调用指向自己的类,这些类会返回测试需要的任何内容。 实现: 1)创建自定义的 URL 协议处理器,需要调用 URL.setURLStreamHandlerFactory() 2) URLStreamHandlerFactory类有一个方法叫createURLStreamHandler(String protocol),该方法根据给定的协议(例如"http:", "ftp:"等)返回一个URLStreamHandler。如果工厂不能处理该协议,那么它应该返回null。
所以这里需要 一个类 自定义类 StubStreamHandlerFactory,并实现 URLStreamHandlerFactory,在其方法createURLStreamHandler() 里实现自定义的 HttpURLStreamHandler 3) StubHttpURLStreamHandler 在 openConnection() 去调用自定义的 HttpURLConnection 类 4)在自定义的 StubHttpURLConnection 类中实现 getInputStream() 返回测试需要的内容
// TestWebClient1 public class TestWebClient1 { @BeforeAll static void setUp() { // URL.setURLStreamHandlerFactory() 用于为URL类设置一个URLStreamHandlerFactory。 // 这个工厂用于创建URLStreamHandler实例,这些实例能够处理URLs的特定部分,比如文件系统路径、网络套接字路径和Mailto: URL路径。 URL.setURLStreamHandlerFactory(new StubStreamHandlerFactory()); } private static class StubStreamHandlerFactory implements URLStreamHandlerFactory { @Override // URLStreamHandlerFactory类有一个方法叫createURLStreamHandler(String protocol),该方法 // 根据给定的协议(例如"http:", "ftp:"等)返回一个URLStreamHandler。如果工厂不能处理该协议,那么它应该返回null。 public URLStreamHandler createURLStreamHandler(String protocol) { return new StubHttpURLStreamHandler(); } } private static class StubHttpURLStreamHandler extends URLStreamHandler { @Override protected URLConnection openConnection(URL url) { return new StubHttpURLConnection(url); } } @Test public void testGetContentOk() throws MalformedURLException { WebClient client = new WebClient(); String workingContent = client.getContent(new URL("http://localhost:/")); assertEquals("It works", workingContent); } }
// StubHttpURLConnection 类的实现 public class StubHttpURLConnection extends HttpURLConnection { // isInput 是告诉我们是否使用了 URL 连接进行输入 private boolean isInput = true; /** * Constructor for the HttpURLConnection. * * @param url the URL */ protected StubHttpURLConnection(URL url) { super(url); } @Override public InputStream getInputStream() throws IOException { if (!isInput) { throw new ProtocolException("Cannot read from URLConnection" + " if doInput=false (call setDoInput(true))"); } // public ByteArrayInputStream(byte buf[]) // String().getBytes() 返回一个 byte[] ByteArrayInputStream readStream = new ByteArrayInputStream(new String("It works").getBytes()); return readStream; } @Override public void disconnect() { } @Override public boolean usingProxy() { return false; } @Override public void connect() throws IOException { } }