-
需要进一步抽象吗?会不会导致过度设计?
-
如果需要进一步抽象的话,如何进行抽象呢?有什么通用的步骤或者法则吗?
-
代码不够简洁?
-
不好维护?
-
不符合个人习惯?
-
过度设计,不好理解?
-
“单测很容易书写,很容易就全覆盖了”,那么这就是可测试的代码;
-
“虽然能写得出来,但是费了老大劲,使用了各种框架和技巧,才覆盖完全”,那么这就是可测试性比较差的代码;
-
“完全不知道如何下手写”,那么这就是不可测试的代码;
BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>();
Thread producerThread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
blockingQueue.add(i + ThreadLocalRandom.current().nextInt(100));
}
});
Thread consumerThread = new Thread(() -> {
try {
while (true) {
Integer result = blockingQueue.take();
System.out.println(result);
}
} catch (InterruptedException ignore) {
}
});
producerThread.start();
consumerThread.start();
}
-
生产者:将 0-9 的每个数字,分别加上 [0,100) 的随机数后通过阻塞队列传递给消费者;
-
消费者:从阻塞队列中获取数字并打印;
-
-
需要测试的逻辑位于异步线程中,对于它什么时候执行?什么时候执行完?都是不可控的;
-
-
-
逻辑中含有随机数;
-
-
-
消费者直接将数据输出到标准输出中,在不同环境中无法确定这里的行为是什么,有可能是输出到了屏幕上,也可能是被重定向到了文件中;
-
-
<-50
-
-
f(-51) == -100
-
-
[-50, 50]
-
-
f(-25) == -50
-
-
-
f(25) == 50
-
-
>50
-
-
f(51) == 100
-
-
边界情况
-
-
f(-50) == -100
-
-
-
f(50) == 100
-
-
每一个分段其实就是代码中的一个条件分支,用例的分支覆盖率达到了 100%;
-
像 2x 这样的逻辑运算,通过几个合适的采样点就可以保证正确性;
-
边界条件的覆盖,就像是分段函数的转折点;
-
函数的返回值只和参数有关,只要参数确定,返回值就是唯一确定的
-
代码中含有远程调用,无法确定这次调用是否会成功;
-
含有随机数生成逻辑,导致行为不确定;
-
执行结果和当前日期有关,比如只有工作日的早上,闹钟才会响起;
return ThreadLocalRandom.current().nextInt(100) + 1;
}
return () -> integerSupplier.get() + 1;
}
Supplier<Integer> result = g(() -> 1);
assert result.get() == 2;
}
return integerSupplier.get() + 1;
}
因为这个例子比较简单,“可测试” 带来的收益看起来没有那么高,真实业务中的逻辑一般比 +1 要复杂多了,此时如果能构建有效的测试将是非常有益的。
Consumer<Supplier<T>> consumer) {
BlockingQueue<T> blockingQueue = new LinkedBlockingQueue<>();
new Thread(() -> producer.accept(blockingQueue::add)).start();
new Thread(() -> consumer.accept(() -> {
try {
return blockingQueue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
})).start();
}
Consumer<Consumer<T>> producer,
Consumer<Supplier<T>> consumer) {
BlockingQueue<T> blockingQueue = new LinkedBlockingQueue<>();
executor.execute(() -> producer.accept(blockingQueue::add));
executor.execute(() -> consumer.accept(() -> {
try {
return blockingQueue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}));
}
producerConsumerInner(Runnable::run,
(Consumer<Consumer<Integer>>) producer -> {
producer.accept(1);
producer.accept(2);
},
consumer -> {
assert consumer.get() == 1;
assert consumer.get() == 2;
});
}
private final Executor executor;
private final BlockingQueue<T> blockingQueue;
public ProducerConsumer(Executor executor) {
this.executor = executor;
this.blockingQueue = new LinkedBlockingQueue<>();
}
public void start() {
executor.execute(this::produce);
executor.execute(this::consume);
}
abstract void produce();
abstract void consume();
protected void produceInner(T item) {
blockingQueue.add(item);
}
protected T consumeInner() {
try {
return blockingQueue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
new ProducerConsumer<Integer>(Runnable::run) {
@Override
void produce() {
produceInner(1);
produceInner(2);
}
@Override
void consume() {
assert consumeInner() == 1;
assert consumeInner() == 2;
}
}.start();
}
很显然这种测试无法验证多线程运行的情况,但我故意这么做的,这部分单元测试的主要目的是验证逻辑的正确性,只有先验证逻辑上的正确性,再去测试并发才比较有意义,在逻辑存在问题的情况下就去测试并发,只会让问题隐藏得更深,难以排查。一般开源项目中会有专门的单元测试去测试并发,但是因为其编写代价比较大,运行时间比较长,数量会远少于逻辑测试。
new ProducerConsumer<Integer>(Executors.newFixedThreadPool(2)) {
@Override
void produce() {
for (int i = 0; i < 10; i++) {
produceInner(i + ThreadLocalRandom.current().nextInt(100));
}
}
@Override
void consume() {
while (true) {
Integer result = consumeInner();
System.out.println(result);
}
}
}.start();
}
-
随机数生成逻辑
-
打印逻辑
private final Supplier<Integer> numberGenerator;
private final Consumer<Integer> numberConsumer;
public NumberProducerConsumer(Executor executor,
Supplier<Integer> numberGenerator,
Consumer<Integer> numberConsumer) {
super(executor);
this.numberGenerator = numberGenerator;
this.numberConsumer = numberConsumer;
}
@Override
void produce() {
for (int i = 0; i < 10; i++) {
produceInner(i + numberGenerator.get());
}
}
@Override
void consume() {
while (true) {
Integer result = consumeInner();
numberConsumer.accept(result);
}
}
}
AtomicInteger expectI = new AtomicInteger();
producerConsumerInner2(Runnable::run, () -> 0, i -> {
assert i == expectI.getAndIncrement();
});
assert expectI.get() == 10;
}
new NumberProducerConsumer(Executors.newFixedThreadPool(2),
() -> ThreadLocalRandom.current().nextInt(100),
System.out::println).start();
}
-
红灯:写用例,运行,无法通过用例
-
绿灯:用最快最脏的代码让测试通过
-
重构:将代码重构得更加优雅
-
代码结构尚未完全确定,出入口尚未明确,即使提前写了单元测试,后面大概率也要修改
-
产品一句话需求,外加对系统不够熟悉,用例很难在开发之前写好
-
启动一个线程,在内存中异步生成 Excel
-
上传 Excel 到钉盘/oss
-
发消息给用户
-
异步执行导致不可测试:抽出一个同步的函数;
-
大量使用 Spring Bean 导致逻辑割裂:将逻辑放到普通的 Java 类或者静态方法中;
-
表单数据,流程与用户的相关信息查询是远程调用,含有副作用:通过高阶函数将这些副作用抽出去;
-
导入状态落入数据库,也是一个副作用:同样通过高阶函数将其抽象出去;
//… 省略具体逻辑, 其中包括所有可测试的逻辑, 包括表单数据转换,excel 生成
}
-
config:数据,表单配置信息,含有哪些控件,以及控件的配置
-
dataService: 函数,用于批量分页查询表单数据的副作用
PageList<FormData> batchGet(String formId, Long cursor, int pageSize);
}
/**
* 将状态切换为 RUNNING
*/
void runningStatus();
/**
* 将状态置为 finish
* @param fileId 文件 id
*/
void finishStatus(Long fileId);
/**
* 将状态置为 error
* @param errMsg 错误信息
*/
void errorStatus(String errMsg);
}
// 这里的 export 就是刚刚展示的导出测试边界
byte[] excelBytes = export(new FormConfig(), new LocalDataService(),
new LocalStatusStore());
assertExcelContent(excelBytes, Arrays.asList(
Arrays.asList(“序号”, “表格”, “表格”, “表格”, “创建时间”, “创建者”),
Arrays.asList(“序号”, “物品编号”, “物品名称”, “xxx”, “创建时间”, “创建者”),
Arrays.asList(“1”, “22”, “火车”, “而非”, “2020-10-11 00:00:00”, “悬衡”)
));
}
-
通过 DataService 的抽象,系统可以支持多种数据源导出,比如来自搜索,或者来自 db 的,只要传入不同的 DataService 实现即可,完全不需要改动和性逻辑;
-
ExportStatusStore 的抽象,让系统有能力使用不同的状态存储,虽然目前使用的是 db,但是也可以在不改核心逻辑的情况下轻松切换成 tair 等其他中间件;
– END –