mooctest全国大学生软件测试大赛开发者赛道相关知识记录(Junit 4)

urlyy

环境要求

  • JDK 8 虽然我只有JDK 17,改了下Language Level,保证不会使用新特性。但是还是改用jdk8了 (后面会讲,变异测试需要jdk8)
  • Junit 4.12(在pom.xml中已指定好)
  • 我用的是 IDEA,毕竟2024年mooctest比赛终于可以不用那个难用得要死的eclipse了

异常处理

1
2
3
4
5
6
7
8
9
10
11
12
13
import org.junit.rules.ExpectedException;

public class ProductTest {
@Rule
public ExpectedException thrown = ExpectedException.none();

@Test(timeout=1000)
public void test() {
thrown.expect(NullPointerException.class);
thrown.expectMessage("qwer");
throw new NullPointerException("1234");
}
}

注意,一个Test方法只能测试一处异常。如果你想测试一个对象方法里会有的多个异常,还是只能分成多个测试方法分开测。

!!!!!可以自己实现assertThrows

其实有个允许一个Test方法内测试多个异常的函数,但是是Junit5中的。通过参考23年省赛答案,我发现了答案中对assertThrows主动进行了实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private void assertThrows(Runnable r, Class<? extends Throwable> exception) {
try {
r.run();
fail();
} catch (Exception e) {
assertTrue(exception.isAssignableFrom(e.getClass()));
}
}

// 我还加了个允许使用报错信息进行比较的
private void assertThrows(Runnable r, Class<? extends Throwable> exception, String message) {
try {
r.run();
fail();
} catch (Exception e) {
assertTrue(exception.isAssignableFrom(e.getClass()));
assertEquals(e.getMessage(),message);
}
}

assertThrows(() -> {
// 调用被测方法
...
methodToTest();
}, IndexOutOfBoundsException.class);

反射读写private字段

有些私有字段我们想主动访问而不是通过调用public方法间接访问,就直接用反射直接读写。

1
2
3
4
5
6
7
Shop shop = new Shop();
Field productsField = shop.getClass().getDeclaredField("products");
productsField.setAccessible(true);
// 写
productsField.set(shop, new ArrayList<Product>());
// 读
ArrayList<Product> value = (ArrayList<Product>) productsField.get(shop);

反射调用私有方法

有些私有方法我们测试不到或者不能直接测试到,就可以通过反射直接调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

class Demo{
private String getMessage(){
return "invoke private method";
}
}

public class TestDemo {
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Demo demo = new Demo();
Class<?> clazz = demo.getClass();
Method privateMethod = clazz.getDeclaredMethod("getMessage");
privateMethod.setAccessible(true);
String result = (String) privateMethod.invoke(demo);
System.out.println(result); // 输出:invoke private method
}
}

assertEquals和assertSame

assertEquals是使用 == 进行比较,assertSame比较两个引用是否指向堆上的同一个对象。

assertEquals(double)

主动提这个是因为IDE会有警告或者报错,要求传入第三个参数,即误差范围。

1
void assertEquals(double expected, double actual, double delta)

查看源码可以发现就是差<=误差就认为相等。

1
Math.abs(d1 - d2) <= delta

遍历枚举

懒得一个一个枚举的测试?直接遍历!

1
2
3
4
5
6
7
8
9
10
11
12
// 定义
public enum ProductEnum {
BOOK("书籍"),
DRINK("饮料"),
ELECTRONICS("电子产品");
...
}

// 遍历
for (ProductEnum productEnum : ProductEnum.values()) {
...
}

@Before和@After

有时我们想对于每个测试方法,都做同一个前置或后置操作(比如创建新对象、将变化的值恢复)。Junit 提供了对应的注解。
这里主要使用了@After和@Before。还有个@BeforeClass,它只在所有Test方法运行前调用一次,且这个注解所在的方法必须是静态方法(这个好理解吧,就是类初始化时调用一次这样)。@AfterClass在此比赛中没用,故不写了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import org.junit.After;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;

import static org.junit.Assert.assertEquals;

class Demo{
public static int cnt = 0;
}

public class TestDemo {

@BeforeClass
public static void beforeClass() {
System.out.println("=== before_class");
}

@Before
public void before() {
System.out.println("*** before");
}

@After
public void after() {
Demo.cnt = 0;
System.out.println(">>> after");
}

@Test
public void test1() {
Demo.cnt ++;
assertEquals(Demo.cnt, 1);
System.out.println("run test1");
}

@Test
public void test2() {
Demo.cnt ++;
assertEquals(Demo.cnt, 1);
System.out.println("run test2");
}

@Test
public void test3() {
Demo.cnt ++;
assertEquals(Demo.cnt, 1);
System.out.println("run test3");
}
}

输出为

1
2
3
4
5
6
7
8
9
10
11
12
=== before_class
*** before
run test1
>>> after
*** before
run test2
>>> after
*** before
run test3
>>> after

Process finished with exit code 0

测试控制台输出

众所周知测试肯定不能修改待测代码,但是对于只有 sout 的 void 方法,我们肯定也是要测试。我们可以通过输出重定向的方法捕获输出内容到变量中,然后 assert。我的这种写法是在每个Test结束后恢复默认out,在部分需要重定向输出的方法内自行setOut。因为重定向后,就不能把自己的日志打印到终端上了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import org.junit.After;
import org.junit.BeforeClass;
import org.junit.Test;

import java.io.ByteArrayOutputStream;
import java.io.PrintStream;

import static org.junit.Assert.assertEquals;

class Demo{
public void print(){
System.out.println("Hello World");
}
}

public class TestDemo {
static PrintStream console = null;

@BeforeClass
public static void before() {
// 初始先保存默认的out
console = System.out;
}

@After
public void after() {
// 每次测试结束,恢复默认out
System.setOut(console);
}

@Test
public void testResult() {
// 输出重定向
ByteArrayOutputStream newOut = new ByteArrayOutputStream();
System.setOut(new PrintStream(newOut));
Demo demo = new Demo();
// 调用它,让它输出,并重定向到 newOut 中
demo.print();
assertEquals("Hello World",newOut.toString().trim());
// 注意如果不reset,输出会不断追加到newOut中
newOut.reset();
demo.print();
assertEquals("Hello World",newOut.toString().trim());
newOut.close();
}
}

根据操作系统适配换行符与路径分隔符

为什么要根据OS适配?具体可以看CSDN的这篇文章:回车换行(CRLF)已过时,应废除!”SQLite 之父的公开呼吁引发热议

1
2
String lineSeparator = System.getProperty("line.separator");
String fileSeparator = System.getProperty("file.separator");

变异测试

变异测试的原理就是,把待测代码(在字节码层面改)改一下,如果我们的测试代码的 assert 依然通过(即变异子存活),那说明代码存在问题。比如我们测试 int subtract(int a,int b){return a-b;} 这个减法函数,如果我们的 junit 写的是 assertEquals(subtract(0,0), 0),那么变异子将subtract的减法改成加法,这个 assert 依然通过,说明咱们的测试存在疏漏。

我的理解是变异测试不是让开发人员自行对代码单元测试的,而是让测试人员完善测试用例的。

我们使用 pitest 这个工具进行测试。包在 pom.xml 里已经有了。

我们使用下面的命令启动测试。

1
mvn org.pitest:pitest-maven:mutationCoverage

注意如果版本 >= jdk9,会报错
Unable to make field private final java.util.Comparator java.util.TreeMap.comparator accessible: module java.base does not "opens java.util" to unnamed module
所以就只能老老实实用 jdk8 了……

按下面这样配一个运行按钮,会方便一点。注意填入的命令是pitest:mutationCoverage

运行成功后,查看target/pit-reports/时间/index.html即可

共享变量被修改导致变异测试失败

可能即便测 coverage 已经全部 pass 了,pitest 这里还是有报错All tests did not pass without mutation when calculating line coverage. Mutation testing requires a green suite. See http://pitest.org for more details.

可能是因为多个方法共享的变量(如静态变量等)被修改导致测试不通过(虽然我也不确定一定是这个问题导致的)。可以看下面的例子,各个Test方法是从上到下执行的,上面方法修改类变量cnt,会影响在下面方法里的测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.junit.Test;
import static org.junit.Assert.assertEquals;

class Demo{
public static int cnt = 0;
}

public class ProductTest2 {
@Test
public void test() {
Demo.cnt ++;
assertEquals(Demo.cnt,1);
}

@Test
public void test2() {
Demo.cnt ++;
assertEquals(Demo.cnt,2);
}
}

变异测试详细学习

突变子列表,这个链接介绍了有哪些突变子以及对应做了什么突变。浏览一下可以方便我们看 index.html 里内容的时候知道是哪些变异子活下来了。

这些是运行 mutation 覆盖率测试时默认激活的 mutator:

1
2
3
4
5
6
7
INCREMENTS_MUTATOR
VOID_METHOD_CALL_MUTATOR
RETURN_VALS_MUTATOR
MATH_MUTATOR
NEGATE_CONDITIONALS_MUTATOR
INVERT_NEGS_MUTATOR
CONDITIONALS_BOUNDARY_MUTATOR

也可以自行配置其他突变子,这会导致测试时间变长,但能发现更多问题。(因为不知道比赛方是怎么配置的,不过这里不用深究,反正没空专门写变异测试)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<configuration>
<targetClasses>
<param>com.baeldung.testing.mutation.*</param>
</targetClasses>
<targetTests>
<param>com.baeldung.mutation.test.*</param>
</targetTests>
<mutators>
<mutator>CONSTRUCTOR_CALLS</mutator>
<mutator>VOID_METHOD_CALLS</mutator>
<mutator>RETURN_VALS</mutator>
<mutator>NON_VOID_METHOD_CALLS</mutator>
</mutators>
</configuration>

在index.html中,悬停在红色数字上可以看到下面黄色区域的内容,就是对应行的存活变异子。

下面的例子就是说,第150行有两个变异子存活,即条件边界改变和条件取反。比如一个是变异成if(index < 2)这种,一个是if(index > 0)这种。我们就要针对这些情况再添加相关assert。

其他注意事项(来自2024省赛说明文件)

  1. 所提交的Java文件名需与代码中的类名保持一致。Java 语法要求代码文件名必须与代码类名一致,否则无法正常执行代码。有部分选手提交的文件名带有数字后缀,如“LibraryTest(1).java”,同样无法执行。
  2. 本届比赛要求选手将所有测试用例写在一个测试类中(最终提交一个 Java 文件)。所提交的测试类不要求必须为题目模板中的测试类,但需符合上述第1条要求。有考生写多个测试类并简单复制到同一类中,导致提交的测试类出现大量语法冲突。
  3. 由于变异测试的特殊性,所提交的所有测试用例均需要正常通过JUnit执行,不可有failure 或 error情况,否则将没有变异测试得分。
  4. 在算分的时候,将使用题目模板项目进行算分,这将导致根据修改过的源代码编写的测试代码可能无法通过执行。
  5. 由于慕测平台长时间不操作会自动退出登录,预选赛中有选手在结束前一分钟内提交答案时,被提醒登录“登录超时”,再次登陆后比赛已结束,导致未能成功提交答案。请各位参赛选手预留出充足的答案提交时间。

做题技巧

  • 某些情况可以多用循环,增加覆盖率的同时,也更好杀死变异体。

  • 先用 idea 的 diagram 看各个类的依赖关系,先覆盖最上层的类的方法,这样也会覆盖一些底层方法。如果先从底层类开始,会浪费时间。同时最好先提高覆盖率,再去弄变异测试,别花费时间在不提高分数的测试用例的编写上。

  • 尽量避免修改共享变量,如果一定要修改,尽量改后还原(Test方法末尾还原或者在@After内处理)。

  • 注意判题标准。反正24年的是下面这样。所以不要只做覆盖率。然后各个Test方法把名称和注释都弄得可读性搞些。运行效率的话,虽然我上面说了可以用循环,但是感觉只要不写死循环就OK吧。注意是分支覆盖,所以不用浪费时间测试那些没有分支的函数如setter、getter

    按以下五个维度进行评分。

    • (30%)分支覆盖率:代码分支覆盖率。
    • (30%)变异杀死率:参阅PIT工具网站指定的常见变异类型。
    • (20%)可读性与可维护性:参阅各大企业的测试同样指南进行评分。
    • (20%)脚本运行效率:针对该题为每个覆盖率区间给定一个基准时间,分数为(基准时间-运行时间)/基准时间。

    脚本编写效率:总分=上述分数累加,总分相同则按提交时间二次排序。

  • 可读性与维护性:可以使用Alibaba Java Coding Guidelines这个插件检查代码,或者直接用 idea 的 problems 面板查看。建议最后留五分钟改改代码可读性。

  • 打完预选赛,根本做不完分支覆盖,可能是我太菜了。所以变异覆盖率也只能靠做分支覆盖时多加几种边缘值assert顺便提升。真的没空专门弄变异覆盖。

  • 先把两个题目大概看下,可能第二题比第一题还容易。。。(指24年省赛的五星改为四星的第二题😓)

闲聊

  • 青科大、广大、重邮应该是有加分,人嘎嘎多,把省一占满了都。
  • 这个比赛争议挺大的,主要是软件测试没必要办比赛、认可度低、监管不严、可以用GPT这些。
  • 个人感觉这个比赛挺抽象的,23年的省赛待测项目里没给test文件夹,一堆人临时学用eclipse建测试文件夹。24年省赛第二题一开始是五星,等我开始做第二题时发现变成了四星,难度比第一题低多了。然后允许用GPT,理论上来说是只不准用向日葵这种软件,反正赛中我看到个女生电脑屏幕界面上微信绿泡泡出现的时长比IDEA的时间久,又或者一个学生没主动写过一行代码,全程用的edge的侧栏copilot,是cv领域大神!真给我整无语了。考场的监管也挺松的,聊天软件不禁用就算了,最重要的是也没有签退机制和提交IP限制,我严重怀疑一些提前出考场的本校学生是回宿舍开黑了😓。
  • 广东比陕西卷多了,我双75在陕西勉强拿了个省一,放广州顶多省二。又不加分又没报销,懒得去花1k拿个国三了。
  • 如果能抽出三四天的空闲、代码理解能力强的可以来玩玩,这个比赛也不要怎么培训,全靠个人的经验和技术水平,拿个省一也够了。

赛题列表(欢迎热心同学一起整合历史赛题)

2024年全国大学生软件测试大赛开发者赛道省赛题目

  • 标题: mooctest全国大学生软件测试大赛开发者赛道相关知识记录(Junit 4)
  • 作者: urlyy
  • 创建于 : 2024-11-20 20:46:41
  • 更新于 : 2024-12-06 01:04:12
  • 链接: https://urlyy.github.io/2024/11/20/mooctest全国大学生软件测试大赛开发者赛道相关知识记录(Junit4)/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论