JUnit 5 单元测试框架深度解析:从基础注解到扩展模型与Mockito集成
1. 项目概述为什么今天还需要深入JUnit 5如果你是一名Java开发者无论你是刚入行的新人还是已经写了多年业务代码的老手单元测试都是一个绕不开的话题。而一提到Java单元测试JUnit几乎是条件反射般的答案。但很多人对JUnit的认知可能还停留在JUnit 4那个Test、Before、After的时代。当项目从JUnit 4迁移到JUnit 5或者新建项目直接使用JUnit 5时不少人会感到一丝困惑这些注解怎么变了这个BeforeEach是干嘛的assertThat去哪了更别提那些听起来很高级的扩展模型、动态测试了。这正是我写这篇解析的初衷。JUnit 5在2017年正式发布它不是一个简单的版本迭代而是一次彻底的重构和理念升级。它由三个不同的子模块组成JUnit Platform提供在JVM上启动测试框架的基础、JUnit Jupiter提供新的编程模型和扩展模型和JUnit Vintage提供对JUnit 3/4的兼容。这意味着它从底层架构上就为现代Java开发尤其是Java 8及以后的Lambda、模块化等特性和更复杂的测试场景做好了准备。在日常工作中我见过太多测试代码写得像“走过场”仅仅是为了满足覆盖率指标而丧失了测试本身作为“设计工具”和“文档”的价值。JUnit 5引入的一系列新特性比如参数化测试、动态测试、嵌套测试、测试接口默认方法等本质上是在鼓励我们写出表达力更强、更易于维护、更能反映业务意图的测试代码。掌握它不仅能提升你的测试效率更能潜移默化地改善你的代码设计思维。接下来我将从最基础的搭建开始一直深入到那些能解决实际痛点的“高级玩法”带你重新认识这个强大的测试框架。2. 环境搭建与基础注解告别JUnit 4的思维定式很多教程一上来就讲注解但我觉得先搞定环境消除配置的恐惧感更重要。JUnit 5的依赖和JUnit 4有显著不同这也是第一个容易踩坑的地方。2.1 构建工具依赖配置如果你使用Maven在pom.xml中需要引入以下依赖。关键点在于JUnit 5的junit-jupiter依赖是一个聚合依赖它通常就足够了。但为了运行测试你还需要配置maven-surefire-plugin插件来确保兼容性。dependencies dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.0/version !-- 请使用最新稳定版本 -- scopetest/scope /dependency /dependencies build plugins plugin artifactIdmaven-surefire-plugin/artifactId version3.0.0-M7/version /plugin /plugins /build为什么这么配junit-jupiter依赖包含了Jupiter的API、引擎和参数化测试等常用模块。而Surefire插件从2.22.0版本开始原生支持JUnit 5无需额外配置就能识别Test等注解。如果你用的是Gradle配置更简单dependencies { testImplementation org.junit.jupiter:junit-jupiter:5.10.0 } test { useJUnitPlatform() }注意千万不要再混用JUnit 4和JUnit 5的依赖。如果老项目迁移需要测试旧的JUnit 4代码可以额外引入junit-vintage-engine但新写的测试类务必使用JUnit 5的注解和断言。2.2 核心生命周期注解语义化的巨大进步JUnit 5的注解包名从org.junit变为了org.junit.jupiter.api这是一个明显的区隔。最核心的生命周期注解变化体现了更好的语义表达Test 这个没变还是用来标记一个测试方法。但它的可访问性可以是package-private了这符合“测试是类实现的一部分”的理念。BeforeEach/AfterEach 替代了JUnit 4的Before/After。每个测试方法执行之前/之后都会运行。这个名字更精确避免了“Before/After是每个还是所有”的歧义。BeforeAll/AfterAll 替代了JUnit 4的BeforeClass/AfterClass。在所有测试方法执行之前/之后运行一次。这里有个关键变化这两个注解修饰的方法必须是static的除非使用TestInstance(Lifecycle.PER_CLASS)注解改变测试实例生命周期。import org.junit.jupiter.api.*; class LifecycleTest { BeforeAll static void initAll() { System.out.println(初始化全局资源例如数据库连接池); } BeforeEach void init() { System.out.println(为当前测试方法准备干净的数据); } Test void testOne() { System.out.println(执行测试一); } Test void testTwo() { System.out.println(执行测试二); } AfterEach void tearDown() { System.out.println(清理当前测试产生的数据); } AfterAll static void tearDownAll() { System.out.println(关闭全局资源); } }运行上述测试输出顺序会清晰地展示生命周期。这种语义化的改进让测试代码的意图一目了然。2.3 断言Assertions与假设Assumptions更强大的验证工具JUnit 5的断言API放在了org.junit.jupiter.api.Assertions类中它全面支持Lambda表达式使得断言失败时的错误信息可以延迟计算提升了性能。import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class AssertionsTest { Test void standardAssertions() { assertEquals(2, 1 1); assertTrue(1 2, 这个失败消息可以自定义); // 使用Lambda只有断言失败时才会执行消息构造逻辑 assertTrue(1 2, () - 这是一个 昂贵的 字符串拼接操作); } Test void groupedAssertions() { // 分组断言所有断言都会执行最后一起报告失败 Person person new Person(John, Doe); assertAll(person properties, () - assertEquals(John, person.getFirstName()), () - assertEquals(Doe, person.getLastName()) ); } Test void exceptionTesting() { // 异常断言写法更符合流式API风格 IllegalArgumentException exception assertThrows( IllegalArgumentException.class, () - { throw new IllegalArgumentException(a message); } ); assertEquals(a message, exception.getMessage()); } }分组断言assertAll是我强烈推荐的一个特性。在JUnit 4中如果第一个断言失败后面的就不会执行你无法知道其他属性是否正确。而assertAll会执行所有子断言最后给出一个汇总报告对于验证一个对象的多个属性非常有用。假设Assumptions是另一个实用工具。它用于在特定条件不满足时跳过测试而不是让测试失败。这常用于“仅在特定环境下运行”的测试。Test void testOnlyOnCiServer() { // 假设环境变量ENV的值是CI才执行后续测试逻辑 Assumptions.assumeTrue(CI.equals(System.getenv(ENV))); // ... 只有在CI服务器上才会执行的测试代码 } Test void testOnlyOnDeveloperWorkstation() { // 假设条件不满足整个测试方法会被标记为Aborted已中止而不是失败 Assumptions.assumeFalse(CI.equals(System.getenv(ENV))); // ... 只有在开发机上才会执行的测试代码 }假设和断言的区分很重要断言是对测试逻辑结果的验证失败意味着功能有问题假设是对测试执行前提的检查不满足意味着当前环境不适合运行此测试。合理使用假设可以让测试集更清晰。3. 核心进阶特性让测试代码更具表达力掌握了基础之后JUnit 5那些真正提升效率和生产力的特性才值得深入。它们能让你摆脱重复、僵化的测试代码编写模式。3.1 参数化测试告别重复的测试方法如果你需要对同一个方法用多组不同的输入和期望输出进行测试在JUnit 4时代你可能需要写多个Test方法或者使用Parameterizedrunner但配置繁琐。JUnit 5的ParameterizedTest是核心武器它需要额外依赖junit-jupiter-params。dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter-params/artifactId version5.10.0/version scopetest/scope /dependency参数化测试的强大之处在于它提供了多种数据源注解1.ValueSource 最简单的数据源支持基本类型、String和Class。ParameterizedTest ValueSource(ints {1, 3, 5, -3, 15}) void testIsOdd(int number) { assertTrue(MathUtils.isOdd(number)); }2.CsvSource/CsvFileSource 处理CSV格式数据适合多参数场景。ParameterizedTest CsvSource({ apple, 5, banana, 6, hello,world, 11 // 包含逗号的值需要用单引号包裹 }) void testStringLength(String input, int expectedLength) { assertEquals(expectedLength, input.length()); } // 或者从类路径下的CSV文件读取 ParameterizedTest CsvFileSource(resources /test-data.csv, numLinesToSkip 1) void testFromFile(String name, int age) { // ... }3.MethodSource 最灵活的方式指定一个返回Stream、Collection等的静态工厂方法作为数据源。ParameterizedTest MethodSource(provideStringsForLengthTest) void testWithMethodSource(String input, int expectedLength) { assertEquals(expectedLength, input.length()); } static StreamArguments provideStringsForLengthTest() { return Stream.of( Arguments.of(apple, 5), Arguments.of(banana, 6), Arguments.of(null, 0) // 甚至可以测试边界情况如null ); }实操心得MethodSource是我最常用的方式因为它可以将复杂的测试数据准备逻辑封装在一个方法里数据来源可以是数据库、文件或任何计算过程可读性和可维护性最好。对于简单的几组数据用CsvSource更直观。3.2 动态测试运行时生成测试用例这是JUnit 5的一个革命性特性。静态的Test和ParameterizedTest在编译时就必须确定所有测试用例。而TestFactory允许你在运行时动态生成测试用例。这对于需要遍历复杂数据结构、或根据外部资源如文件系统、数据库查询结果生成测试的场景极其有用。一个TestFactory方法必须返回Stream、Collection、Iterable或Iterator类型的DynamicNode通常是DynamicTest。import org.junit.jupiter.api.*; class DynamicTestsDemo { TestFactory StreamDynamicTest dynamicTestsFromStream() { // 输入数据流 ListString inputList Arrays.asList(apple, banana, orange); // 预期的输出 ListInteger outputList Arrays.asList(5, 6, 6); // 构建动态测试流 return inputList.stream() .map(word - DynamicTest.dynamicTest( Test length of: word, // 动态生成的测试名 () - { int index inputList.indexOf(word); assertEquals(outputList.get(index), word.length()); } )); } TestFactory CollectionDynamicTest generateTestsFromDatabase() { // 假设从数据库读取了一批测试配置 ListTestConfig configs fetchTestConfigsFromDB(); return configs.stream() .map(config - DynamicTest.dynamicTest( Test config: config.getId(), () - executeTestWithConfig(config) )) .collect(Collectors.toList()); } }应用场景想象一下你需要测试一个文件解析器对目录下所有特定格式文件的支持情况。使用动态测试你可以遍历目录为每个文件生成一个独立的测试用例测试报告会清晰列出每个文件的通过与否而不是一个笼统的“解析测试”。注意事项动态测试不支持生命周期注解BeforeEach等。每个动态测试的执行都是独立的。如果需要在动态测试前后执行准备和清理工作需要在TestFactory方法内部或DynamicTest的可执行代码中手动处理。3.3 嵌套测试与测试接口更好的测试组织随着测试类越来越庞大逻辑上相关的测试方法混杂在一起可读性变差。Nested注解可以帮助我们按逻辑层次组织测试。class StackTest { StackObject stack; Test void isEmpty() { /* ... */ } Nested class WhenNew { BeforeEach void createNewStack() { stack new Stack(); } Test void isEmpty() { assertTrue(stack.isEmpty()); } Nested class AfterPushing { String anElement an element; BeforeEach void pushAnElement() { stack.push(anElement); } Test void isNotEmpty() { assertFalse(stack.isEmpty()); } Test void returnElementWhenPopped() { /* ... */ } } } }嵌套测试类可以拥有自己的生命周期方法BeforeEach等并且可以继承外层类的成员变量。这种结构像在写一篇技术文档清晰地描述了“当对象处于某种状态时它应该表现出何种行为”。在IDE中它们通常以可折叠的树形结构展示一目了然。测试接口默认方法是另一个组织代码的利器。你可以将重复的测试逻辑例如某个接口的所有实现都应满足的契约定义在接口的default方法中并用Test、BeforeEach等注解标记。然后让具体的测试类实现这个接口。interface TestLifecycleLogger { BeforeAll static void beforeAll() { System.out.println(Before all tests in the interface); } BeforeEach default void beforeEachTest() { System.out.println(Before each test (from interface)); } } interface EqualsContractTestT { T createValue(); T createNotEqualValue(); Test default void valueEqualsItself() { T value createValue(); assertEquals(value, value); } Test default void valueDoesNotEqualDifferentValue() { T value1 createValue(); T value2 createNotEqualValue(); assertNotEquals(value1, value2); } } class StringTests implements TestLifecycleLogger, EqualsContractTestString { Override public String createValue() { return foo; } Override public String createNotEqualValue() { return bar; } // 还可以添加String特有的测试 Test void specificStringTest() { /* ... */ } }这种方式实现了测试逻辑的复用和契约的集中定义非常适合为API或抽象类定义一套通用的合规性测试套件。4. 扩展模型Extension Model定制你的测试引擎JUnit 5最强大的设计之一就是用扩展模型Extension Model取代了JUnit 4的Runner机制。Runner是“独占”的一个测试类只能指定一个Runner这限制了功能的组合。而扩展模型是“可组合”的你可以通过ExtendWith注解注册多个扩展它们像插件一样在测试生命周期的不同阶段介入。JUnit 5提供了一些内置扩展但理解其模型后你可以编写自己的扩展来解决特定问题。4.1 常用内置扩展TempDir 一个字段级注解用于在测试方法或类执行期间创建临时文件和目录并在执行后自动清理。这解决了测试中文件资源管理的难题。Test void writeToTempFile(TempDir Path tempDir) throws IOException { Path testFile tempDir.resolve(test.txt); Files.writeString(testFile, hello); assertEquals(hello, Files.readString(testFile)); } // 测试结束后tempDir及其内容会被自动删除Timeout 方法级或类级注解为测试执行设置超时时间。防止因死锁、无限循环等问题导致测试套件挂起。Test Timeout(5) // 单位秒 void failsIfExecutionTimeExceedsFiveSeconds() throws InterruptedException { Thread.sleep(10_000); // 这个测试会失败 }4.2 理解扩展点与自定义扩展JUnit Jupiter定义了一系列扩展点API接口如BeforeAllCallback/AfterAllCallbackBeforeEachCallback/AfterEachCallbackBeforeTestExecutionCallback/AfterTestExecutionCallbackParameterResolver 用于动态解析测试方法或构造函数的参数。TestExecutionExceptionHandler 用于处理测试执行过程中抛出的异常。InvocationInterceptor 更底层的拦截器高级用法。编写一个自定义扩展就是实现这些接口。例如我们创建一个简单的扩展在每个测试方法执行前后打印日志public class TimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback { private static final Logger logger LoggerFactory.getLogger(TimingExtension.class); private static final String START_TIME start time; Override public void beforeTestExecution(ExtensionContext context) { getStore(context).put(START_TIME, System.currentTimeMillis()); } Override public void afterTestExecution(ExtensionContext context) { long startTime getStore(context).remove(START_TIME, long.class); long duration System.currentTimeMillis() - startTime; logger.info(方法 [{}] 执行耗时 {} ms, context.getRequiredTestMethod().getName(), duration); } private ExtensionContext.Store getStore(ExtensionContext context) { // Store用于在同一测试执行上下文中存储和检索数据 return context.getStore(ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod())); } } // 使用扩展 ExtendWith(TimingExtension.class) class MyServiceTest { Test void someTest() { /* ... */ } }更实用的例子数据库事务回滚扩展。在集成测试中我们通常希望每个测试方法在一个独立的事务中运行测试后回滚避免污染数据库。虽然Spring Test提供了这样的功能但理解原理后我们可以用JUnit 5扩展模拟一个简化版public class DatabaseTransactionExtension implements BeforeEachCallback, AfterEachCallback { Override public void beforeEach(ExtensionContext context) { Connection conn getConnection(); // 获取数据库连接 conn.setAutoCommit(false); // 开启事务 // 将连接存储到上下文中供测试方法或ParameterResolver使用 context.getStore(ExtensionContext.Namespace.GLOBAL).put(TEST_TRANSACTION_CONNECTION, conn); } Override public void afterEach(ExtensionContext context) { Connection conn context.getStore(ExtensionContext.Namespace.GLOBAL) .remove(TEST_TRANSACTION_CONNECTION, Connection.class); if (conn ! null) { conn.rollback(); // 回滚事务 conn.close(); } } // 还可以实现一个ParameterResolver将Connection注入到测试方法参数中 static class ConnectionResolver implements ParameterResolver { Override public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { return parameterContext.getParameter().getType().equals(Connection.class); } Override public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { return extensionContext.getStore(ExtensionContext.Namespace.GLOBAL) .get(TEST_TRANSACTION_CONNECTION); } } }实操心得扩展模型的学习曲线稍陡但它是将测试中的横切关注点如日志、事务、安全性、模拟数据注入模块化的终极工具。在大型项目中将通用的测试基础设施封装成扩展能极大提升测试代码的整洁度和一致性。开始时可以多参考junit-jupiter源码中内置扩展的实现。5. 与Mockito等Mock框架的深度集成单元测试的核心是“隔离”我们通常使用Mock框架如Mockito来模拟依赖对象的行为。JUnit 5与Mockito的集成比JUnit 4更优雅。5.1 使用MockitoExtension最直接的方式是使用Mockito为JUnit 5提供的官方扩展。首先添加依赖dependency groupIdorg.mockito/groupId artifactIdmockito-junit-jupiter/artifactId version5.7.0/version !-- 使用与Mockito核心兼容的版本 -- scopetest/scope /dependency然后在测试类上使用ExtendWith(MockitoExtension.class)。ExtendWith(MockitoExtension.class) class ServiceTestWithMockito { Mock private DependencyRepository repositoryMock; // 被Mock的依赖 InjectMocks private ServiceUnderTest service; // 待测服务Mock会自动注入 Test void testWithMock() { // 1. 定义Mock行为 when(repositoryMock.findById(anyLong())).thenReturn(Optional.of(new Entity(test))); // 2. 执行待测方法 Result result service.process(1L); // 3. 验证结果和交互 assertNotNull(result); verify(repositoryMock).findById(1L); // 验证方法被以特定参数调用 } }Mock创建Mock对象InjectMocks创建待测类的实例并自动将Mock或Spy标注的字段注入进去。这种方式避免了手动调用MockitoAnnotations.openMocks(testClass)的样板代码。5.2 参数解析器ParameterResolver与Mock结合JUnit 5的扩展模型允许你将Mock对象作为测试方法的参数传入这进一步提升了灵活性。MockitoExtension本身就注册了这样的ParameterResolver。ExtendWith(MockitoExtension.class) class ParameterInjectionTest { Test void testWithMockAsParameter(Mock DependencyRepository repositoryMock) { // repositoryMock 已经是一个初始化好的Mock对象 ServiceUnderTest service new ServiceUnderTest(repositoryMock); // ... 测试逻辑 } Test void testWithMultipleMocks(Mock DependencyRepository repo, Mock AnotherService anotherService) { // 可以注入多个Mock } }这种方式让测试方法更加自包含依赖关系一目了然特别适合在需要为不同测试方法配置不同Mock行为的场景。5.3 严格Stubbing与验证Mockito与JUnit 5结合可以更好地利用Mockito的一些高级特性比如严格Stubbing。在测试类上添加MockitoSettings(strictness Strictness.STRICT_STUBS)可以开启严格模式。在此模式下不必要的Stubbing即定义了但未在测试中使用的when(...).thenReturn(...)会导致测试失败这有助于保持测试的简洁和准确避免遗留无用的Mock配置。ExtendWith(MockitoExtension.class) MockitoSettings(strictness Strictness.STRICT_STUBS) class StrictMockTest { // ... }6. 测试报告、过滤与执行顺序当测试套件变得庞大时如何管理和控制测试的执行就变得重要。6.1 标签Tag与测试过滤你可以使用Tag注解为测试类或方法打上标签然后根据标签来过滤执行哪些测试。class TaggedTests { Test Tag(fast) void fastTest() { /* 快速单元测试 */ } Test Tag(slow) Tag(integration) // 可以打多个标签 void integrationTest() { /* 耗时的集成测试 */ } }在Maven中过滤plugin artifactIdmaven-surefire-plugin/artifactId configuration !-- 只运行标记为‘fast’的测试 -- groupsfast/groups !-- 排除标记为‘slow’的测试 -- excludedGroupsslow/excludedGroups /configuration /plugin在Gradle中过滤test { useJUnitPlatform { includeTags fast excludeTags slow } }在IDE中大多数IDE如IntelliJ IDEA都支持在运行配置中按标签过滤。这非常有用例如在本地开发时只运行“fast”标签的单元测试而在CI流水线中才运行所有包括“integration”标签的测试。6.2 自定义测试显示名称DisplayName注解可以为测试类或方法提供一个更易读的名称这个名称会出现在测试报告和IDE中替代默认的方法名。DisplayName(计算器测试套件) class CalculatorTests { Test DisplayName(1 1 应该等于 2) void onePlusOneEqualsTwo() { assertEquals(2, 1 1); } Test DisplayName(除以零应该抛出 ArithmeticException) void divisionByZeroShouldThrowException() { assertThrows(ArithmeticException.class, () - 1 / 0); } }你甚至可以使用DisplayNameGeneration指定一个生成器或者使用ParameterizedTest的name属性来为参数化测试的每个用例生成动态名称这大大提升了测试报告的可读性。6.3 测试执行顺序默认情况下JUnit 5并不保证测试方法的执行顺序这是有意为之的以确保测试的独立性。但有时出于性能考虑比如需要昂贵的外部资源初始化或遗留代码原因你可能需要指定顺序。可以使用TestMethodOrder注解。TestMethodOrder(MethodOrderer.OrderAnnotation.class) // 使用Order注解指定顺序 class OrderedTests { Test Order(1) void firstTest() { System.out.println(第一个执行); } Test Order(2) void secondTest() { System.out.println(第二个执行); } Test Order(3) void thirdTest() { System.out.println(第三个执行); } }除了OrderAnnotation还有MethodName按方法名字母顺序、Random随机等排序器。但务必谨慎使用测试顺序依赖顺序的测试往往是脆弱的不易维护。优先考虑使用BeforeAll进行一次性初始化或者重构测试使其完全独立。7. 从JUnit 4迁移到JUnit 5平滑升级指南对于已有大量JUnit 4测试的项目全盘重写是不现实的。JUnit 5通过junit-vintage-engine模块提供了良好的向后兼容性。第一步添加依赖确保你的pom.xml或build.gradle中包含了JUnit 5的依赖如第2.1节所述并且保留了现有的JUnit 4依赖如junit:junit:4.13.2。同时添加junit-vintage-engine。dependency groupIdorg.junit.vintage/groupId artifactIdjunit-vintage-engine/artifactId version5.10.0/version scopetest/scope /dependency这样你的构建工具Surefire就能同时运行JUnit 4通过Vintage引擎和JUnit 5通过Jupiter引擎的测试。第二步并行运行与逐步迁移现在新旧测试可以共存。你可以新写的测试全部使用JUnit 5org.junit.jupiter.api包。旧的JUnit 4测试无需修改继续运行。在时间允许时逐步将重要的、经常修改的JUnit 4测试类迁移到JUnit 5。第三步迁移单个测试类迁移一个类时通常需要做以下更改包导入将org.junit下的导入改为org.junit.jupiter.api。注解替换Before-BeforeEachAfter-AfterEachBeforeClass-BeforeAll(同时将方法改为static)AfterClass-AfterAll(同时将方法改为static)Ignore-Disabled断言迁移org.junit.Assert-org.junit.jupiter.api.Assertions注意方法名变化如assertThat在JUnit 5中不再直接提供建议使用Assertions自带的断言或结合Hamcrest、AssertJ等第三方断言库。规则Rule迁移JUnit 4的Rule和ClassRule在JUnit 5中被扩展模型取代。常见的规则如TemporaryFolder、ExpectedException在JUnit 5中都有对应的替代品TempDir、assertThrows。对于自定义规则需要重写为扩展Extension。Runner迁移RunWith被ExtendWith取代。例如RunWith(SpringRunner.class)变为ExtendWith(SpringExtension.class)。SpringBootTest等Spring Boot测试注解已经集成了JUnit 5支持通常无需显式添加ExtendWith。常见问题排查测试不运行检查构建插件配置如Maven Surefire版本≥2.22.0检查依赖冲突。迁移后测试失败重点检查断言逻辑和异常测试。JUnit 5的assertThrows会返回异常对象供进一步验证而JUnit 4的Test(expected...)或ExpectedException规则行为略有不同。生命周期方法不执行确认BeforeAll/AfterAll方法是否为static或者测试类是否使用了TestInstance(Lifecycle.PER_CLASS)。迁移过程可能是渐进的核心目标是让新的测试享受JUnit 5的新特性同时保证旧的测试资产继续发挥作用。利用好并行运行期可以大大降低迁移风险。