踩坑日记之Gradle自定义JacocoReport跟Test task

本文记录了在Gradle中配置Jacoco单元测试覆盖率报告时遇到的问题,包括自定义test任务为何被SKIPPED,如何使用onlyIf条件,以及解决test.exec文件不存在和默认包含的问题。通过分析源码和调整配置,实现了预期的测试报告目标。

起因

最近新写了一个项目,为了更好的保证项目输出的质量,引入了单元测试覆盖率统计框架Jacoco。由于gradle官网上的案例只有几个默认的task(test、JacocoTestReport等)的设置,而我希望能够额外为不同的层提供单独的test,也就发生了接下来这些有趣的事儿。

1. 为什么JacocoTestReport总是被SKIPPED

首先,我写了一个自定义的test。并且通过将finalizedBy指定为JacocoTestReport来让test执行完毕后自动执行JacocoTestReport。自定义test以及JacocoTestReport 如下:

//自定义test
task serviceTest(type: Test) {
    useTestNG()
    useJUnitPlatform()

	finalizedBy jacocoTestReport

    jacoco {
        enabled = true
        //指定原始数据文件位置
        destinationFile = layout.buildDirectory.file("jacoco/${taskName}.exec").get().asFile
        includes = ['xxxservice']
        excludeClassLoaders = []
        includeNoLocationClasses = false
        sessionId = "<auto-generated value>"
        output = JacocoTaskExtension.Output.FILE
    }
}

jacocoTestReport {
    // tests are required to run before generating the report
    dependsOn serviceTest
		
	//指定覆盖率统计数据文件。
	executionData(layout.buildDirectory.file("jacoco/${serviceTest.name}.exec").get().asFile)

    afterEvaluate {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it, include: ['xxxservice'])
        }))
    }
    reports {
        xml.required = false
        csv.required = false
        html.outputLocation = layout.buildDirectory.dir('jacocoReport')
    }
}

看起来似乎没啥问题,该做的都做了。但是执行的时候程序却跳过了jacocoTestReport。当时特别纳闷,就去网上提了个问题。最后gradle官方的工作人员给了答复,大概意思是executionData对应的文件不存在,在jacocoTestReport里加上onlyIf = {true} 可以保证jacocoTestReport会执行,并且执行最终会报出test.exec文件不存在的错误。ok,有方向了,那就一个一个来分析。

2. task里的onlyIf

事实上,onlyIf决定了task是否被执行。同时,onlyIf可以设置多个规则判断,如果所有的规则判断都返回为true,才会执行task。否则,跳过task

2.1 SkipOnlyIfTaskExecuter

让我们进入SkipOnlyIfTaskExecuter一探究竟:

public class SkipOnlyIfTaskExecuter implements TaskExecuter {
	@Override
    public TaskExecuterResult execute(TaskInternal task, TaskStateInternal state, TaskExecutionContext context) {
        boolean skip = !task.getOnlyIf().isSatisfiedBy(task);
				
		//跳过task
        if (skip) {
            return TaskExecuterResult.WITHOUT_OUTPUTS;
        }
				
		//执行task
        return executer.execute(task, state, context);
    }

	//遍历所有规则
	public boolean isSatisfiedBy(T object) {
        Spec<? super T>[] specs = getSpecsArray();
        for (Spec<? super T> spec : specs) {
            if (!spec.isSatisfiedBy(object)) {
								//只要有一个规则不通过,则返回false
                return false;
            }
        }
        return true;
    }
}

2.2 onlyIf用法

那么,我么怎么使用onlyIf呢?

2.2.1 自定义规则作为唯一判断条件

这里用=即可。

task foo{
	onlyIf = {true}
}
//或者
foo.onlyIf = {true}

这里我们可以从gradle源码AbstractTask#setOnlyIf 看出来

	public void setOnlyIf(final Closure onlyIfClosure) {
        taskMutator.mutate("Task.setOnlyIf(Closure)", new Runnable() {
            @Override
            public void run() {
				//设置onlyIfClosure并覆盖原有的onlyIfSpec
                onlyIfSpec = createNewOnlyIfSpec().and(onlyIfClosure);
            }
        });
    }

	//创建一个新的默认返回为true的element
	private AndSpec<Task> createNewOnlyIfSpec() {
        return new AndSpec<Task>(new Spec<Task>() {
            @Override
            public boolean isSatisfiedBy(Task element) {
				//调用方: task.isSatisfiedBy(task)
                return element == AbstractTask.this && enabled;
            }
        });
    }

2.2.2 自定义规则作为条件判断的一部分

去掉=即可

task foo{
	onlyIf {true}
}
//或者
foo.onlyIf {true}

源码部分:

	public void onlyIf(final Closure onlyIfClosure) {
        taskMutator.mutate("Task.onlyIf(Closure)", new Runnable() {
            @Override
            public void run() {
				//插入
                onlyIfSpec = onlyIfSpec.and(onlyIfClosure);
            }
        });
    }

这种方式可以方便我们定义多个onlyIf块,同时也可以保证不会覆盖系统默认的onlyIf。接下来我们来聊聊jacocoTestReport预置的onlyIf

3. jacocoTestReportonlyIf为什么"默认"是false

从上面的分析可知jacocoTestReport会被跳过显然是isSatisfiedBy返回了false。而正常情况通过createNewOnlyIfSpec()初始化的specisSatisfiedBy返回的必然是true。所以,肯定是什么地方添加了额外的spec。我们先看看JacocoReport这种类型的Task是如何初始化的:

/**
 *  JacocoReport extends JacocoReportBase
 *  JacocoReportBase extends AbstractTask
 */
public abstract class JacocoReportBase extends JacocoBase {

	public JacocoReportBase() {
		//添加spec
        onlyIf(new Spec<Task>() {
            @Override
            public boolean isSatisfiedBy(Task element) {
                return Iterables.any(getExecutionData(), new Predicate<File>() {
                    @Override
                    public boolean apply(File file) {
                    	//返回文件是否存在
                        return file.exists();
                    }
                });
            }
        });
    }
}

可以看到,JacocoReport在初始化时会添加一个验证规则,如果getExecutionData()对应的文件都存在,则返回true,否则返回false 。那么,onlyIf"默认"为false,显然是getExecutionData()返回的文件路径中有的文件不存在。接下来我们研究为什么会有文件不存在的问题。

4. jacocoTestReportexecutionData为什么会默认包含"test.exec"

经过上述分析,我们知道只要为task设置onlyIf={true}之后,task就必然会执行。通过添加onlyIf={true},jacocoTestReport开始运行。这是突然又得到一个错误:

Unable to read execution data file /xxx/test.exec

很奇怪,明明我们设置的executionDataserviceTest.exec,为什么jacocoTestReport会去查找test.exec呢?显然,这又是被预置的行为。所以,我们先来看看jacocoTestReport这个Task是在哪里创建出来的:

public class JavaPlugin implements Plugin<Project> {
		public static final String TEST_TASK_NAME = "test";
}

public class JacocoPlugin implements Plugin<Project> {

	private void addDefaultReportAndCoverageVerificationTasks(final JacocoPluginExtension extension) {
        project.getPlugins().withType(JavaPlugin.class, javaPlugin -> {

			//获取test Task
            TaskProvider<Task> testTaskProvider = project.getTasks().named(JavaPlugin.TEST_TASK_NAME);
			//初始化jacocoTestReport Task
            addDefaultReportTask(extension, testTaskProvider);
        });
    }
		
	private void addDefaultReportTask(final JacocoPluginExtension extension, final TaskProvider<Task> testTaskProvider) {
		//testTaskName是常量test
        final String testTaskName = testTaskProvider.getName();
		//注册jacocoTestReport Task
        project.getTasks().register(
            "jacoco" + StringUtils.capitalize(testTaskName) + "Report",
            JacocoReport.class,
            reportTask -> {
				//定义该Task的默认配置项
				...
				//设置executionData为test里定义的destinationFile
                reportTask.executionData(testTaskProvider.get());
                ...
            });
    }
}

public abstract class JacocoReportBase extends JacocoBase {

	public void executionData(Task... tasks) {
        for (Task task : tasks) {
			//拿到传入task里定义的jacoco extension,也就是jacoco{}定义的内容
            final JacocoTaskExtension extension = task.getExtensions().findByType(JacocoTaskExtension.class);
            if (extension != null) {
                executionData(new Callable<File>() {
                    @Override
                    public File call() {
						//设置executionData为task里定义的destinationFile
                        return extension.getDestinationFile();
                    }
                });
                mustRunAfter(task);
            }
        }
    }
}

可以看到,JacocoPlugin在初始化时会注册一个类型为JacocoReport而名称为jacocoTestReportTask,这个TaskexecutionData默认设置为test Task的jacoco extension中定义的destinationFile。而上文中的testjacoco extension里定义的destinationFile正好是layout.buildDirectory.file("jacoco/${taskName}.exec").get().asFile。所以到这里其实也就真相大白了。

事实上,类型为TestTaskjacoco extensiondestinationFile默认值就是layout.getBuildDirectory().file("jacoco/" + taskName + ".exec").map(RegularFile::getAsFile)。所以,如果我们没有定义这个值,也会报出同样的错误。源码部分如下:

public class JacocoPlugin implements Plugin<Project> {
	public void apply(Project project) {
        JacocoPluginExtension extension = project.getExtensions().create(PLUGIN_EXTENSION_NAME, JacocoPluginExtension.class, project, agent);
        //为test设置默认jacoco extension
        applyToDefaultTasks(extension);
        //初始化默认jacocoTestReport
        addDefaultReportAndCoverageVerificationTasks(extension);
    }

	private void applyToDefaultTasks(final JacocoPluginExtension extension) {
		//拿到所有test Task并配置jacoco extension
        project.getTasks().withType(Test.class).configureEach(extension::applyTo);
    }
}

public class JacocoPluginExtension {
	public <T extends Task & JavaForkOptions> void applyTo(final T task) {
        final String taskName = task.getName();
        final JacocoTaskExtension extension = task.getExtensions().create(TASK_EXTENSION_NAME, JacocoTaskExtension.class, objects, agent, task);
		//设置文件名为${taskName}.exec。test Task对应的就是test.exec
        extension.setDestinationFile(layout.getBuildDirectory().file("jacoco/" + taskName + ".exec").map(RegularFile::getAsFile));
    }
}

5. 怎么实现最初的目标

既然我们知道了jacocoTestReport里预设了executionData,那么要实现最初目标,我们只需要在执行task时把它清空并重新设置,或者自定义一个干净的JacocoReport即可。

5.1 清空并重新设置

jacocoTestReport {
    // tests are required to run before generating the report
    dependsOn serviceTest
		//清空预设的executionData
		((DefaultConfigurableFileCollection) executionData).filesWrapper.clear();
		//指定覆盖率统计数据文件。
		executionData(layout.buildDirectory.file("jacoco/${serviceTest.name}.exec").get().asFile)

    afterEvaluate {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it, include: ['xxxservice'])
        }))
    }
    reports {
        xml.required = false
        csv.required = false
        html.outputLocation = layout.buildDirectory.dir('jacocoReport')
    }
}

5.2 自定义一个干净的JacocoReport

task jacocoServiceTestReport(type: JacocoReport) {
    // tests are required to run before generating the report
    dependsOn serviceTest

    //自定义的不会初始化源码目录,需要手动指定
    sourceSets sourceSets.main

    executionData(layout.buildDirectory.file("jacoco/${serviceTest.name}.exec").get().asFile)

    afterEvaluate {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it, include: project.ext.serviceSources)
        }))
    }
    reports {
        xml.required = false
        csv.required = false
        html.outputLocation = layout.buildDirectory.dir(project.ext.reportDir as String)
    }

    onlyIf = { true }
}

一些唠叨(没什么营养,可跳过)

当时碰到这个问题,没有头绪。就跑到StackOverFlow上瞎逛过一圈。其中有一个相同的问题,但下面的解答不对。最后还是我解决问题后跑到下面给了解答(作为一个gradle菜鸟,能帮助到他人,很开心😃!)。

总结

因为对gradlejacoco不怎么熟,当时看到唯一的提示信息:jacocoTestReport SKIPPED我是一脸懵逼的。好在当时从stackOverFlow以及官方那里得到了一个切入点:onlyIf={true}。让问题最终得以解决。

另一方面,我觉得官方在这一块的sample略显简陋,这些默认行为至少应该提一下,否则很容易让人摸不着头脑。所以我翻文档终究没有翻出解决方案(如果有哪位朋友发现官方文档有过这方面的描述,欢迎指出👏)。

github

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值