去年冬天,我为仍在工作的客户编写并发布了一项服务。 总体而言,该服务满足了业务需求和性能要求,但是使用该服务的一个团队告诉我,他们经常遇到一个问题,该问题是该服务将返回500个错误,直到重新启动该服务才恢复正常。 我问这是什么时候发生的, 戴上了侦探的帽子。
在此博客中,我将介绍诊断错误并确定正确的集成测试解决方案以正确方式进行修复的过程。 为此,我必须创建一个测试,以准确再现服务在PROD中遇到的情况。 我必须创建一个修复程序,使测试从失败到通过。 最后,我努力提高对所有未来发行版代码正确性的信心,这只有通过自动测试才能实现。
诊断错误
在500个错误开始发生时,我会仔细阅读服务的日志文件。 他们很快发现了一个非常严重的问题:在星期六的午夜之前,我的服务将开始引发错误。 最初,所有SQLException都发生了各种各样的错误,但最终根本原因是相同的:
org.springframework.jdbc.CannotGetJdbcConnectionException: Could not get JDBC Connection; nested exception is java.sql.SQLRecoverableException: IO Error: The Network Adapter could not establish the connection
at org.springframework.jdbc.datasource.DataSourceUtils.getConnection(DataSourceUtils.java:80)
此过程持续了几个小时,直到次日凌晨重新启动服务,服务恢复正常为止。
与检查 洞穴巨魔 DBA,我发现要连接的数据库已关闭以进行维护。 确切的细节使我无所适从,但我认为这是数据库关闭的大约30分钟的窗口。 因此,很明显,一旦数据库从中断中恢复,我的服务就无法重新连接到数据库。
修复此错误(和我过去经常去过的错误)的最直接方法是使用Google“从数据库中断中恢复”,这很可能导致我遇到一个Stack Overflow线程,该线程可以回答我的问题。 然后,我将在提供的答案中“复制并粘贴”并推送要测试的代码。
如果生产受到错误的严重影响,则在短期内可能需要使用此方法。 就是说,应该在不久的将来留出时间来用自动测试来覆盖更改。
因此,通常情况下,“正确的方式”做事通常意味着大量的字体加载时间投资,这句话在这里肯定是正确的。
但是,投资回报是花费在修复错误上的时间减少了,对代码正确性的信心增加了,此外,测试可以作为文档在给定场景下的行为的重要形式。
尽管这个特定的测试用例有些深奥,但在设计和编写测试(无论是单元测试还是集成测试)时要牢记这一重要因素:给测试起好名字,确保测试代码可读性,等等。
解决方案1:模拟一切
我为该问题编写测试的第一个步骤是尝试“模拟一切”。 尽管Mockito和其他模拟框架非常强大,并且变得越来越容易使用,但在考虑了此解决方案之后,我很快得出结论,就是我永远不会有信心,除了模拟之外,我不会进行任何测试已经写了。
获得“绿色”结果并不会增加我对代码正确性的信心,而这首先是编写自动化测试的全部要点! 转到另一种方法。
解决方案2:使用内存数据库
我编写测试的下一个尝试是使用内存数据库。 我是H2的忠实拥护者,过去我广泛使用H2,希望它可以再次满足我的需求。 我在这里的时间可能比我应该花费的时间多。
虽然最终这种方法没有成功,但花费的时间并没有完全浪费,我确实学到了更多有关H2的知识。 以“正确的方式”做事的好处之一(尽管此刻通常很痛苦)是您可以学到很多东西。 所获得的知识在当时可能没有用,但以后可能会有价值。
使用内存数据库的优势
就像我说的那样,我在这里的时间可能比我应该花的时间更多,但是我确实有希望这种解决方案起作用的原因。 H2和其他内存数据库具有两个非常理想的特征:
- 速度: H2的启动和停止相当快,不到一秒。 因此,尽管比使用模拟慢一些,但我的测试仍会很快。
- 可移植性: H2可以完全从导入的jar运行,因此其他开发人员可以仅提取我的代码并运行所有测试,而无需执行任何其他步骤。
另外,我最终的解决方案有两个非常重要的缺点,下面将作为解决方案的一部分进行介绍。
编写测试
有点有意义,但是到目前为止,我还没有编写任何一行生产代码。 TDD的主要原则是先编写测试,然后编写生产代码。 这种方法论以及确保高水平的测试覆盖率还鼓励开发人员仅进行必要的更改。 这回到了提高对代码正确性的信心这一目标。
以下是我用来测试PROD问题的初始测试用例:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = DataSourceConfig.class, properties = {"datasource.driver=org.h2.Driver",
"datasource.url=jdbc:h2:mem:;MODE=ORACLE", "datasource.user=test", "datasource.password=test" })
public class ITDatabaseFailureAndRecovery {
@Autowired
private DataSource dataSource;
@Test
public void test() throws SQLException {
Connection conn = DataSourceUtils.getConnection(dataSource);
conn.createStatement().executeQuery("SELECT 1 FROM dual");
ResultSet rs = conn.createStatement().executeQuery("SELECT 1 FROM dual");
assertTrue(rs.next());
assertEquals(1, rs.getLong(1));
conn.createStatement().execute("SHUTDOWN");
DataSourceUtils.releaseConnection(conn, dataSource);
conn = DataSourceUtils.getConnection(dataSource);
rs = conn.createStatement().executeQuery("SELECT 1 FROM dual");
assertTrue(rs.next());
assertEquals(1, rs.getLong(1));
}
}
最初,我觉得使用此解决方案的方向正确。 有一个问题是如何启动H2服务器备份(一次有一个问题!),但是当我运行测试时,它失败了,并给出了与我的服务在PROD中所经历的类似的错误:
org.h2.jdbc.JdbcSQLException: Database is already closed (to disable automatic closing at VM shutdown, add ";DB_CLOSE_ON_EXIT=FALSE" to the db URL) [90121-192]
但是,如果我修改测试用例并仅尝试第二次连接数据库:
conn = DataSourceUtils.getConnection(dataSource);
异常消失了,我的测试通过了,而无需更改生产代码。 这里不对劲…
为什么此解决方案不起作用
因此,使用H2将不起作用。 实际上,我花了很多时间尝试使H2正常工作,而不是上面建议的时间。 包括故障排除尝试; 连接到基于文件的H2服务器实例,而不只是一个内存中的远程H2服务器; 我什至偶然发现了H2 Server类 , 该类本来可以解决早先的服务器关闭/启动问题。
这些尝试显然都没有效果。 H2的基本问题(至少对于此测试用例而言)是,尝试连接到数据库(如果当前未运行)将导致该数据库启动。 正如我的初始测试用例所示,这有点延迟,但是显然这构成了一个基本问题。 在PROD中,当我的服务尝试连接到数据库时,它不会导致数据库启动(无论我尝试连接多少次)。 我的服务日志肯定可以证明这一事实。 接下来是另一种方法。
解决方案3:连接到本地数据库
模拟一切都行不通。 使用内存数据库也不会成功。 看来,我能够正确重现我的服务在PROD中遇到的方案的唯一方法是连接到更正式的数据库实现。 关闭共享开发数据库是不可能的,因此该数据库实现需要在本地运行。
该解决方案的问题
因此,在此之前的所有内容都应该很好地表明我确实希望避免走这条路。 我的沉默有一些很好的理由:
- 可移植性降低:如果另一个开发人员想要运行此测试,则需要在本地计算机上下载并安装数据库。 她还需要确保她的配置详细信息符合测试的期望。 这是一项耗时的任务,并且至少会导致一定数量的“带外”知识。
- 速度较慢:总体而言,我的测试仍然不太慢,但是启动,关闭和重新启动(即使是针对本地数据库)也需要花费几秒钟的时间。 虽然几秒钟听起来不算多,但可以通过足够的测试来累加时间。 这是一个主要的问题,因为允许集成测试花费更长的时间(以后要花更多的时间),但是集成测试越快,运行它们的频率就越高。
- 组织争执:要在构建服务器上运行此测试,意味着我现在需要与已经负担过重的DevOps团队合作,在构建框中设置数据库。 即使操作团队没有负担过重,我也想尽可能避免这种情况,因为这只是又一步。
- 许可:在我的代码示例中,我使用MySQL作为测试数据库实现。 但是,对于我的客户,我正在连接到Oracle数据库。 Oracle确实免费提供了Oracle Express Edition(XE),但确实有规定。 这些规定之一是不能同时运行两个Oracle XE实例。 除了Oracle XE的特殊情况外,在连接到特定产品时,许可可能成为一个问题,这一点要牢记。
…最后
最初,这篇文章要长很多,这也给所有 鲜血,汗水和眼泪 到现在为止的工作。 最终,这些信息对读者而言并不是特别有用,即使这是作者写信的方式。 因此,事不宜迟,一个测试可以准确地重现我的服务在PROD中遇到的情况:
@Test
public void testServiceRecoveryFromDatabaseOutage() throws SQLException, InterruptedException, IOException {
Connection conn = null;
conn = DataSourceUtils.getConnection(datasource);
assertTrue(conn.createStatement().execute("SELECT 1"));
DataSourceUtils.releaseConnection(conn, datasource);
LOGGER.debug("STOPPING DB");
Runtime.getRuntime().exec("/usr/local/mysql/support-files/mysql.server stop").waitFor();
LOGGER.debug("DB STOPPED");
try {
conn = DataSourceUtils.getConnection(datasource);
conn.createStatement().execute("SELECT 1");
fail("Database is down at this point, call should fail");
} catch (Exception e) {
LOGGER.debug("EXPECTED CONNECTION FAILURE");
}
LOGGER.debug("STARTING DB");
Runtime.getRuntime().exec("/usr/local/mysql/support-files/mysql.server start").waitFor();
LOGGER.debug("DB STARTED");
conn = DataSourceUtils.getConnection(datasource);
assertTrue(conn.createStatement().execute("SELECT 1"));
DataSourceUtils.releaseConnection(conn, datasource);
}
完整代码在这里: https : //github.com/wkorando/integration-test-example/blob/master/src/test/java/com/integration/test/example/ITDatabaseFailureAndRecovery.java
修复
所以我有我的测试用例。 现在是时候编写生产代码以使我的测试显示为绿色。 最终,我从一个朋友那里得到了答案,但是可能会在使用足够的谷歌搜索功能时偶然发现了它。
最初,我在服务配置中设置的数据源实际上看起来像这样:
@Bean
public DataSource dataSource() {
org.apache.tomcat.jdbc.pool.DataSource dataSource = new org.apache.tomcat.jdbc.pool.DataSource();
dataSource.setDriverClassName(env.getRequiredProperty("datasource.driver"));
dataSource.setUrl(env.getRequiredProperty("datasource.url"));
dataSource.setUsername(env.getRequiredProperty("datasource.user"));
dataSource.setPassword(env.getRequiredProperty("datasource.password"));
return dataSource;
}
我的服务遇到的潜在问题是,当来自DataSource
的连接池的连接未能连接到数据库时,它变得“不好”。 然后,下一个问题是我的DataSource
实现不会从连接池中删除这些“不良”连接。 它只是不断尝试使用它们。
幸运的是,此修复非常简单。 当DataSource
从连接池中检索连接时,我需要指示DataSource
测试连接。 如果此测试失败,则连接将从池中删除,并尝试建立新的连接。 我还需要为DataSource
提供一个查询,它可以用来测试连接。
最后(并非绝对必要,但对测试很有用),默认情况下,我的DataSource
实现仅每30秒测试一次连接。 但是,我的测试可以在不到30秒的时间内运行。 最终,这段时间的长度并没有真正意义,因此我添加了一个由属性文件提供的验证间隔。
这是我更新后的DataSource
外观:
@Bean
public DataSource dataSource() {
org.apache.tomcat.jdbc.pool.DataSource dataSource = new org.apache.tomcat.jdbc.pool.DataSource();
dataSource.setDriverClassName(env.getRequiredProperty("datasource.driver"));
dataSource.setUrl(env.getRequiredProperty("datasource.url"));
dataSource.setUsername(env.getRequiredProperty("datasource.user"));
dataSource.setPassword(env.getRequiredProperty("datasource.password"));
dataSource.setValidationQuery("SELECT 1");
dataSource.setTestOnBorrow(true);
dataSource.setValidationInterval(env.getRequiredProperty("datasource.validation.interval"));
return dataSource;
}
关于编写集成测试的最后一点说明。 最初,我创建了一个测试配置文件,该文件用于配置要在测试中使用的DataSource
。 但是,这是不正确的。
问题是,如果有人要从生产配置文件中删除我的修订,但将其保留在测试配置文件中,则我的测试仍会通过,但是我的实际生产代码将再次受到我这段时间花的问题的影响定影! 这是一个容易想象的错误。 因此,在编写集成测试时,请确保使用实际的生产配置文件。
自动化测试
因此,即将结束。 我有一个测试用例,可以准确地重现我在PROD中遇到的情况。 我有一个修复程序,然后使我的测试从失败到通过。 但是,所有这些工作的重点不仅是让我确信我的修订适用于下一个版本,而且还适用于所有将来的版本。
Maven用户:希望您已经熟悉surefire插件 。 或者,至少希望您的DevOps团队已经设置了父pom,以便在构建服务器上构建项目时,每次提交都会花费您花时间编写的所有单元测试。
但是,本文不是关于编写单元测试的,而是关于编写集成测试的 。 集成测试套件的运行时间(有时数小时)通常比单元测试套件的时间(不超过5-10分钟)长得多。 集成测试通常也更容易波动。 虽然我在本文中编写的集成测试应该是稳定的-如果它破裂了,应该引起关注-在连接到开发数据库时,您不能总是百分百地确信数据库将可用或测试数据将是正确的甚至是存在的。 因此,失败的集成测试并不一定意味着代码不正确。
幸运的是,Maven背后的人们已经解决了这个问题,那就是带有故障安全插件 。 默认情况下,surefire插件将查找Test
之前或之后固定的类,而failsafe插件将查找IT
(集成测试)之前或之后固定的类。 像所有Maven插件一样,您可以配置插件应执行的目标。 这使您可以灵活地使每次代码提交都运行单元测试,而集成测试仅在夜间构建时运行。 这也可以防止需要部署修补程序但不存在集成测试所依赖的资源的情况。
最后的想法
编写集成测试既耗时又困难。 它需要广泛考虑您的服务将如何与其他资源交互。 当您专门测试故障场景时,此过程甚至更加困难且耗时,这通常需要对测试所连接的资源进行更深入的控制,并借鉴过去的经验和知识。
尽管花费了大量的时间和精力,但这项投资将随着时间的推移多次收回投资。 只有通过自动测试才能提高对代码正确性的信心,这对缩短开发反馈周期至关重要。
我在本文中使用的代码可以在这里找到: https : //github.com/wkorando/integration-test-example 。