-
Notifications
You must be signed in to change notification settings - Fork 127
Add headless game-test/unit-test harness with script-side AssertJ assertions and coverage #1153
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 2601
Are you sure you want to change the base?
Changes from 3 commits
27525cc
1301955
4cd4470
9b19b4e
84ee77a
eb09321
9ddaf10
3c51c74
1fa06a8
847baf5
00c1f2d
3db42ce
70d1bdc
22c0127
b696ab2
189ab72
4ed6a1d
cbcd0d8
b94742c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| name: Coverage | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| pull_request: | ||
| branches: | ||
| - "2601" | ||
| paths: | ||
| - "gradle/**" | ||
| - "**.java" | ||
| - "**.kts" | ||
| - "**.properties" | ||
| - "**/coverage.yml" | ||
| - "src/test/**" | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| permissions: | ||
| contents: read | ||
| pull-requests: write | ||
|
|
||
| jobs: | ||
| coverage: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Setup JDK 25 | ||
| uses: actions/setup-java@v4 | ||
| with: | ||
| distribution: "microsoft" | ||
| java-version: "25" | ||
|
|
||
| - name: Setup Gradle | ||
| uses: gradle/actions/setup-gradle@v4 | ||
|
|
||
| # Runs the JUnit (`test`) and headless game-test (`runGametest`) JVMs, merges their JaCoCo | ||
| # data into one report, and derives build/reports/coverage/coverage-summary.json. | ||
| - name: Generate coverage | ||
| run: ./gradlew coverageSummaryJson | ||
|
|
||
| - name: Upload HTML report | ||
| if: always() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: coverage-report-html | ||
| path: build/reports/coverage/html | ||
|
|
||
| # Same-repo PRs only: PRs from forks get a read-only token and cannot comment. For fork | ||
| # coverage comments, split this into a companion `workflow_run` job that has write access. | ||
| - name: Comment coverage on PR | ||
| if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} | ||
| uses: actions/github-script@v7 | ||
| with: | ||
| script: | | ||
| const fs = require('fs'); | ||
| const s = JSON.parse(fs.readFileSync('build/reports/coverage/coverage-summary.json', 'utf8')); | ||
| const row = (label, m) => `| ${label} | ${m.percent}% | ${m.covered} | ${m.missed} |`; | ||
| const marker = '<!-- kubejs-coverage -->'; | ||
| const body = [ | ||
| marker, | ||
| '## Coverage report', | ||
| '', | ||
| '| Metric | Coverage | Covered | Missed |', | ||
| '| --- | --- | --- | --- |', | ||
| row('Instructions', s.instruction), | ||
| row('Branches', s.branch), | ||
| row('Lines', s.line), | ||
| '', | ||
| '_Merged from the JUnit unit tests and the headless game test. Full HTML report is in the `coverage-report-html` artifact._', | ||
| ].join('\n'); | ||
|
|
||
| const { owner, repo } = context.repo; | ||
| const issue_number = context.issue.number; | ||
| const comments = await github.rest.issues.listComments({ owner, repo, issue_number }); | ||
| const existing = comments.data.find(c => c.body && c.body.includes(marker)); | ||
| if (existing) { | ||
| await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); | ||
| } else { | ||
| await github.rest.issues.createComment({ owner, repo, issue_number, body }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,13 @@ | ||
| import com.almostreliable.almostgradle.dependency.LoadingMode | ||
| import java.util.Locale | ||
| import org.gradle.testing.jacoco.plugins.JacocoTaskExtension | ||
| import org.gradle.testing.jacoco.tasks.JacocoReport | ||
|
|
||
| plugins { | ||
| id("net.neoforged.moddev") version "2.0.138" | ||
| id("com.almostreliable.almostgradle") version "2.1.1" | ||
| id("idea") | ||
| jacoco | ||
| // id("me.shedaniel.unified-publishing") version "0.1.+" | ||
| } | ||
|
|
||
|
|
@@ -23,7 +27,11 @@ almostgradle.setup { | |
| withAccessTransformerValidation = !runningInCI | ||
|
|
||
| tests { | ||
|
|
||
| enabled = true | ||
| testMod = true | ||
| gameTests = true | ||
| testFramework = true | ||
| jUnit = true | ||
| } | ||
|
|
||
| recipeViewers { | ||
|
|
@@ -61,6 +69,15 @@ neoForge { | |
| from(file("interfaces.json")) | ||
| publish(file("interfaces.json")) | ||
| } | ||
|
|
||
| runs { | ||
| // AlmostGradle's gametest run launches as a plain server here; use Neo's headless type and run only our tests. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this required because you want to run the unit tests when the game tests are running? I could add a flag to AG that enables this automatically if required.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Without this section it starts up a regular minecraft server rather than a gametest server. Even kicks off the old server GUI. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have now fixed this in Almost Gradle 2.2.0. When you update, this should no longer be necessary. The game test property name was updated, which I must have missed. Please report it the next time you discover something like this. 😄 I also added a new option to run the unit tests in the game test run config, which you configured here as well. However, as written in the README, I would not encourage enabling this behavior. Unit tests are invoked in the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I just pushed changes for the latest version and removed the section like you had suggested. |
||
| matching { it.name == "gametest" }.configureEach { | ||
| type.set("gameTestServer") | ||
| programArgument("--tests") | ||
| programArgument("testmod:*") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| repositories { | ||
|
|
@@ -121,6 +138,113 @@ dependencies { | |
| }) | ||
| } | ||
|
|
||
| // Make the game tests' KubeJS scripts available in the gametest run's game directory. | ||
| val copyGameTestScripts by tasks.registering(Copy::class) { | ||
| mustRunAfter("prepareGametestRun") | ||
| from(layout.projectDirectory.dir("src/test/kubejs")) | ||
| into(layout.buildDirectory.dir("tmp/gametestRuns/gametest/kubejs")) | ||
| } | ||
|
|
||
| tasks.matching { it.name == "runGametest" }.configureEach { | ||
| dependsOn(copyGameTestScripts) | ||
| } | ||
|
|
||
| // Code coverage. JaCoCo's agent is attached to both test JVMs and scoped to KubeJS' own classes; | ||
| // `coverageReport` merges the two execution-data files into one report. | ||
| val coverageIncludes = listOf("dev.latvian.mods.kubejs.*") | ||
|
|
||
| jacoco { | ||
| toolVersion = "0.8.15" // 0.8.14+ supports Java 25 class files | ||
| // Gradle's JaCoCo plugin only instruments `Test` tasks by default; opt the gametest JavaExec in. | ||
| applyTo(tasks.withType<JavaExec>().matching { it.name == "runGametest" }) | ||
| } | ||
|
|
||
| // When a coverage task is requested, force a fresh run of both test JVMs so the report always | ||
| // reflects a real execution (otherwise Gradle's up-to-date check skips them and reuses old data). | ||
| val coverageRequested = gradle.startParameter.taskNames.any { | ||
| it.substringAfterLast(':').startsWith("coverage") | ||
| } | ||
|
|
||
| // The test source set holds both JUnit unit tests (...unittest, run by `test`) and the game-test | ||
| // mod (...testmod, run by `runGametest`). Allow `test` to pass before any unit tests are present. | ||
| tasks.named<Test>("test") { | ||
| failOnNoDiscoveredTests = false | ||
| if (coverageRequested) { | ||
| outputs.upToDateWhen { false } | ||
| } | ||
| extensions.configure<JacocoTaskExtension> { | ||
| includes = coverageIncludes | ||
| } | ||
| } | ||
|
|
||
| tasks.withType<JavaExec>().matching { it.name == "runGametest" }.configureEach { | ||
| if (coverageRequested) { | ||
| outputs.upToDateWhen { false } | ||
| } | ||
| extensions.configure<JacocoTaskExtension> { | ||
| isEnabled = true | ||
| includes = coverageIncludes | ||
| } | ||
| } | ||
|
|
||
| val coverageReport by tasks.registering(JacocoReport::class) { | ||
| group = "verification" | ||
| description = "Merges coverage from the JUnit (test) and game-test (runGametest) JVMs." | ||
| dependsOn("test", "runGametest") | ||
| sourceSets(sourceSets["main"]) | ||
| executionData(fileTree(layout.buildDirectory) { | ||
| include("jacoco/test.exec", "jacoco/runGametest.exec") | ||
| }) | ||
| reports { | ||
| html.required = true | ||
| csv.required = true | ||
| xml.required = false | ||
| html.outputLocation = layout.buildDirectory.dir("reports/coverage/html") | ||
| csv.outputLocation = layout.buildDirectory.file("reports/coverage/coverage.csv") | ||
| } | ||
| } | ||
|
|
||
| // Derive a small JSON summary (overall instruction/branch/line %) from the JaCoCo CSV, for CI to | ||
| // read when posting the coverage PR comment. JaCoCo has no native JSON report. | ||
| val coverageSummaryJson by tasks.registering { | ||
| group = "verification" | ||
| description = "Writes coverage-summary.json from the JaCoCo CSV report." | ||
| dependsOn(coverageReport) | ||
| val csvFile = layout.buildDirectory.file("reports/coverage/coverage.csv") | ||
| val jsonFile = layout.buildDirectory.file("reports/coverage/coverage-summary.json") | ||
| inputs.file(csvFile) | ||
| outputs.file(jsonFile) | ||
| doLast { | ||
| val rows = csvFile.get().asFile.readLines().drop(1).filter { it.isNotBlank() } | ||
| var instrMissed = 0L; var instrCovered = 0L | ||
| var branchMissed = 0L; var branchCovered = 0L | ||
| var lineMissed = 0L; var lineCovered = 0L | ||
| for (row in rows) { | ||
| val c = row.split(',') | ||
| if (c.size < 9) continue | ||
| instrMissed += c[3].toLong(); instrCovered += c[4].toLong() | ||
| branchMissed += c[5].toLong(); branchCovered += c[6].toLong() | ||
| lineMissed += c[7].toLong(); lineCovered += c[8].toLong() | ||
| } | ||
| fun pct(covered: Long, missed: Long): String { | ||
| val total = covered + missed | ||
| val v = if (total == 0L) 0.0 else covered * 100.0 / total | ||
| return String.format(Locale.ROOT, "%.2f", v) | ||
| } | ||
| fun metric(name: String, covered: Long, missed: Long) = | ||
| """ "$name": { "covered": $covered, "missed": $missed, "percent": ${pct(covered, missed)} }""" | ||
| val json = buildString { | ||
| appendLine("{") | ||
| appendLine(metric("instruction", instrCovered, instrMissed) + ",") | ||
| appendLine(metric("branch", branchCovered, branchMissed) + ",") | ||
| appendLine(metric("line", lineCovered, lineMissed)) | ||
| appendLine("}") | ||
| } | ||
| jsonFile.get().asFile.writeText(json) | ||
| logger.lifecycle("Coverage (instructions): ${pct(instrCovered, instrMissed)}% -> ${jsonFile.get().asFile}") | ||
| } | ||
| } | ||
|
|
||
| publishing { | ||
| repositories { | ||
| val mavenUrl = System.getenv("MAVEN_URL") ?: return@repositories | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package dev.latvian.mods.kubejs.testmod; | ||
|
|
||
| import net.minecraft.resources.Identifier; | ||
| import net.neoforged.bus.api.IEventBus; | ||
| import net.neoforged.fml.ModContainer; | ||
| import net.neoforged.fml.common.Mod; | ||
| import net.neoforged.testframework.conf.FrameworkConfiguration; | ||
|
|
||
| /// Entry point for the {@code testmod} game-test mod. Stands up a NeoForge test framework that collects | ||
| /// the mod's annotated tests and registers them on the mod bus. Loaded only by the headless | ||
| /// {@code runGametest} ({@code gameTestServer}) run. | ||
| @Mod("testmod") | ||
| public class KubeJSGameTests { | ||
| public static final String MOD_ID = "testmod"; | ||
|
|
||
| public KubeJSGameTests(IEventBus modBus, ModContainer container) { | ||
| var framework = FrameworkConfiguration | ||
| .builder(Identifier.fromNamespaceAndPath(MOD_ID, "tests")) | ||
| .build() | ||
| .create(); | ||
|
|
||
| framework.init(modBus, container); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package dev.latvian.mods.kubejs.testmod; | ||
|
|
||
| import java.util.HashSet; | ||
| import java.util.Set; | ||
|
|
||
| /// Bound into scripts as {@code TestRuntime} so a script can report a passing condition back to | ||
| /// the game test that drives it, e.g. {@code TestRuntime.pass('block.break.dirt')}. | ||
| public class TestRuntime { | ||
| private static final Set<String> PASSED = new HashSet<>(); | ||
|
|
||
| public static void pass(String id) { | ||
| PASSED.add(id); | ||
| } | ||
|
|
||
| public static boolean passed(String id) { | ||
| return PASSED.contains(id); | ||
| } | ||
|
|
||
| public static void reset() { | ||
| PASSED.clear(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package dev.latvian.mods.kubejs.testmod; | ||
|
|
||
| import dev.latvian.mods.kubejs.plugin.KubeJSPlugin; | ||
| import dev.latvian.mods.kubejs.script.BindingRegistry; | ||
|
|
||
| public class TestRuntimePlugin implements KubeJSPlugin { | ||
| @Override | ||
| public void registerBindings(BindingRegistry bindings) { | ||
| bindings.add("TestRuntime", TestRuntime.class); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package dev.latvian.mods.kubejs.testmod.block; | ||
|
|
||
| import dev.latvian.mods.kubejs.testmod.TestRuntime; | ||
| import net.minecraft.core.BlockPos; | ||
| import net.minecraft.world.level.GameType; | ||
| import net.minecraft.world.level.block.Blocks; | ||
| import net.neoforged.testframework.DynamicTest; | ||
| import net.neoforged.testframework.annotation.ForEachTest; | ||
| import net.neoforged.testframework.annotation.TestHolder; | ||
| import net.neoforged.testframework.gametest.EmptyTemplate; | ||
| import net.neoforged.testframework.gametest.GameTest; | ||
|
|
||
| @ForEachTest(groups = "kubejs.block.event") | ||
| public class BlockEventTests { | ||
| @GameTest | ||
| @EmptyTemplate(floor = true) | ||
| @TestHolder(value = "block_broken_dirt", description = "KubeJS BlockEvents.broken fires when a player breaks dirt") | ||
|
hspragg-godaddy marked this conversation as resolved.
|
||
| static void blockBrokenDirt(final DynamicTest test) { | ||
| test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL)) | ||
| .thenExecute(() -> TestRuntime.reset()) | ||
| .thenExecute(() -> helper.setBlock(1, 2, 1, Blocks.DIRT.defaultBlockState())) | ||
| .thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(new BlockPos(1, 2, 1)))) | ||
| .thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.break.dirt"), "script did not report block.break.dirt")) | ||
| .thenSucceed()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| @NullMarked | ||
| package dev.latvian.mods.kubejs.testmod; | ||
|
|
||
| import org.jspecify.annotations.NullMarked; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package dev.latvian.mods.kubejs.unittest; | ||
|
|
||
| import dev.latvian.mods.kubejs.util.ListJS; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import java.util.Arrays; | ||
| import java.util.List; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertNull; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
|
|
||
| public class ListJSTest { | ||
| @Test | ||
| void primitiveArrayBecomesBoxedList() { | ||
| assertEquals(List.of(1, 2, 3), ListJS.of(new int[]{1, 2, 3})); | ||
| } | ||
|
|
||
| @Test | ||
| void existingListIsReturnedAsIs() { | ||
| var list = List.of("a", "b"); | ||
| assertEquals(list, ListJS.of(list)); | ||
| } | ||
|
|
||
| @Test | ||
| void orSelfWrapsScalar() { | ||
| assertEquals(List.of("hello"), ListJS.orSelf("hello")); | ||
| } | ||
|
|
||
| @Test | ||
| void orSelfOfNullIsEmpty() { | ||
| assertTrue(ListJS.orSelf(null).isEmpty()); | ||
| } | ||
|
|
||
| @Test | ||
| void ofScalarIsNull() { | ||
| assertNull(ListJS.of("hello")); | ||
| } | ||
|
|
||
| @Test | ||
| void ofSetDeduplicates() { | ||
| var set = ListJS.ofSet(Arrays.asList(1, 1, 2, 3, 3)); | ||
| assertEquals(3, set.size()); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.