测试多线程代码
一般来说,如果一段代码是多线程的,那它不应该属于单元测试的范畴,而是应该通过集成测试保障。
保持简单
- 尽量减少线程控制代码与应用代码的重叠
- 重新设计代码,可以在线程不参与的情况下进行测试,专注业务块
- 编写针对多线程执行逻辑的代码,进行有重点的测试
- 详细他人的工作,使用已经验证多的多线程工具和库
示例
ProfileMatcher
/*** * Excerpted from "Pragmatic Unit Testing in Java with JUnit", * published by The Pragmatic Bookshelf. * Copyrights apply to this code. It may not be used to create training material, * courses, books, articles, and the like. Contact us if you are in doubt. * We make no guarantees that this code is fit for any purpose. * Visit http://www.pragmaticprogrammer.com/titles/utj2 for more book information. ***/ package iloveyouboss; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.BiConsumer; import java.util.stream.Collectors; public class ProfileMatcher { private Map<String, Profile> profiles = new HashMap<>(); private static final int DEFAULT_POOL_SIZE = 4; private ExecutorService executor = Executors.newFixedThreadPool(DEFAULT_POOL_SIZE); protected ExecutorService getExecutor() { return executor; } public void add(Profile profile) { profiles.put(profile.getId(), profile); } public void findMatchingProfiles(Criteria criteria, MatchListener listener, List<MatchSet> matchSets, BiConsumer<MatchListener, MatchSet> processFunction) { for (MatchSet set : matchSets) { Runnable runnable = () -> processFunction.accept(listener, set); executor.execute(runnable); } executor.shutdown(); } public void findMatchingProfiles(Criteria criteria, MatchListener listener) { findMatchingProfiles(criteria, listener, collectMatchSets(criteria), this::process); } // 进一步将业务逻辑拆分出来,然后直接针对process就可以测试了 public void process(MatchListener listener, MatchSet set) { if (set.matches()) { listener.foundMatch(profiles.get(set.getProfileId()), set); } } public List<MatchSet> collectMatchSets(Criteria criteria) { List<MatchSet> matchSets = profiles.values().stream().map(profile -> profile.getMatchSet(criteria)) .collect(Collectors.toList()); return matchSets; } }
ProfileMatcherTest
package iloveyouboss; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.function.BiConsumer; import java.util.stream.Collectors; import org.junit.Before; import org.junit.Test; import static org.junit.Assert.*; import static org.hamcrest.CoreMatchers.*; import static org.mockito.Mockito.*; public class ProfileMatcherTest { private BooleanQuestion question; private Criteria criteria; private ProfileMatcher matcher; private Profile matchingProfile; private Profile nonMatchingProfile; private MatchListener listener; @Before public void create() { question = new BooleanQuestion(1, ""); criteria = new Criteria(); criteria.add(new Criterion(matchingAnswer(), Weight.MustMatch)); matchingProfile = createMatchingProfile("matching"); nonMatchingProfile = createNonMatchingProfile("nonMatching"); } @Before public void createMatcher() { matcher = new ProfileMatcher(); } @Before public void createMatchListner() { listener = mock(MatchListener.class); } @Test public void collectsMatchSets() { matcher.add(matchingProfile); matcher.add(nonMatchingProfile); List<MatchSet> sets = matcher.collectMatchSets(criteria); assertThat(sets.stream().map(set -> set.getProfileId()).collect(Collectors.toSet()), equalTo(new HashSet<>(Arrays.asList(matchingProfile.getId(), nonMatchingProfile.getId())))); } @Test public void processNotifiesListenerOnMatch() { matcher.add(matchingProfile); MatchSet set = matchingProfile.getMatchSet(criteria); matcher.process(listener, set); verify(listener).foundMatch(matchingProfile, set); } @Test public void processDoesNotNotifyListenerWhenNoMatch() { matcher.add(nonMatchingProfile); MatchSet set = nonMatchingProfile.getMatchSet(criteria); matcher.process(listener, set); verify(listener, never()).foundMatch(nonMatchingProfile, set); } @Test public void gathersMatchingProfiles() { Set<String> processedSets = Collections.synchronizedSet(new HashSet<>()); BiConsumer<MatchListener, MatchSet> processFunction = (listener, set) -> { processedSets.add(set.getProfileId()); }; List<MatchSet> matchSets = createMatchSets(100); matcher.findMatchingProfiles(criteria, listener, matchSets, processFunction); while (!matcher.getExecutor().isTerminated()) ; assertThat(processedSets, equalTo(matchSets.stream().map(MatchSet::getProfileId).collect(Collectors.toSet()))); } private List<MatchSet> createMatchSets(int count) { List<MatchSet> sets = new ArrayList<>(); for (int i = 0; i < count; i++) { sets.add(new MatchSet(String.valueOf(i), null, null)); } return sets; } private Answer matchingAnswer() { return new Answer(question, Bool.TRUE); } private Answer nonMatchingAnswer() { return new Answer(question, Bool.FALSE); } private Profile createMatchingProfile(String name) { Profile profile = new Profile(name); profile.add(matchingAnswer()); return profile; } private Profile createNonMatchingProfile(String name) { Profile profile = new Profile(name); profile.add(nonMatchingAnswer()); return profile; } }
测试数据库
- 对于持久层代码,继续用桩没有意义,这时候需要编写一些低速测试以保证访问持久层的数据没有问题
- 为了避免拖累已有的用例执行速度,可以将持久层的测试与其他的测试分离,而放到集成测试部分中去处理
- 由于集成测试存在的难度,尽量以单元测试的形式覆盖,以减少集成测试的数量
总结
采用单元测试测试多线程的关键在于将业务代码与多线程代码区分开,由不同的用例分别负责测试业务逻辑的正确性与多线程代码执行的正确性。
- ProfileMatcher是一个多线程处理类
- 方法findMatchingProfiles(Criteria criteria, MatchListener listener, List<MatchSet> matchSets, BiConsumer<MatchListener, MatchSet> processFunction),负责线程的启动和运行,业务行为通过processFunction传入(Java8之前不知道怎么解决)
- 业务逻辑在process()方法中,不涉及任何多线程的内容
- 经过上面的拆分,可以将业务代码与多线程逻辑分别编写测试
- gathersMatchingProfiles负责测试多线程代码,直接植入了一个新定义的processFunction,体现了Java8将方法作为一类对象的强大能力
- processNotifiesListenerOnMatch和processDoesNotNotifyListenerWhenNoMatch负责测试业务逻辑
- 总结一下
- 如果想轻松的做单元测试,需要仔细设计代码,短小、内聚,减少依赖,良好的分层
- 高级一点可以做TDD,习惯之后代码质量会有很大提高
- 学会利用桩和mock解决一些苦难的测试
- 对于某些困难的场景(多线程、持久存储)的测试,本质上也是将关注点拆分来进行测试
- 单元测试需要配合持续集成才能达到最好的效果
- 团队如果没有意识到单元测试的价值,推广是没有用的,蛮力和硬性要求只能有驱动作用,但如果没有内在的自觉性一定会流于形式
- 关注点分离,将业务逻辑与多线程、持久化存储等依赖分类,单独进行测试
- 使用mock来避免依赖缓慢和易变的数据
- 根据需要 编写集成测试,但是保持简单