From 1552119cef6fecd10ed26d41a621a001c8c82058 Mon Sep 17 00:00:00 2001 From: Patrick Lawson Date: Tue, 31 Aug 2021 20:42:23 -0400 Subject: [PATCH] Add proof-of-concept Java junit test rule. (#12177) This implementation is just good enough to demonstrate how to use the existing Pants Java infrastructure to compile and consume Java source for the purpose of executing junit tests. This initial iteration has several limitations: * jUnit5 (org.junit.platform:junit-platform-console:1.7.2) is hard-coded as the JUnit runner in the rule source. As needed, this can be hoisted into a subsystem for configurability. By design, junit4 is not supported as a **runner**, because its classpath scanning isn't powerful enough. However, junit4 tests can still be run with the junit5 runner. * junit_tests targets have the same requirement of java_library targets that there must be exactly 1 coursier_lockfile dependency in the transitive closure of the junit_tests target. In practice this means that any third party dependencies required by the test source must also be shared by the library targets upon which the test transitively depends. Lockfile subsetting will mostly make this a non-issue, but it's still unfortunate that all test targets are indirectly locked to all other test targets that transitively depend on the same Java library code. * Due to #12293, the test runner currently hard-codes the Coursier `--system-jvm` argument. Future revisions will expose this as an option via `junit_test` parameters and/or a junit subsystem. [ci skip-rust] [ci skip-build-wheels] --- src/python/pants/backend/java/test/BUILD | 5 + .../pants/backend/java/test/__init__.py | 0 src/python/pants/backend/java/test/junit.py | 125 ++++ .../pants/backend/java/test/junit_test.py | 607 ++++++++++++++++++ 4 files changed, 737 insertions(+) create mode 100644 src/python/pants/backend/java/test/BUILD create mode 100644 src/python/pants/backend/java/test/__init__.py create mode 100644 src/python/pants/backend/java/test/junit.py create mode 100644 src/python/pants/backend/java/test/junit_test.py diff --git a/src/python/pants/backend/java/test/BUILD b/src/python/pants/backend/java/test/BUILD new file mode 100644 index 00000000000..eb7faedf707 --- /dev/null +++ b/src/python/pants/backend/java/test/BUILD @@ -0,0 +1,5 @@ +# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md). +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +python_library() +python_tests(name="tests", timeout=240) diff --git a/src/python/pants/backend/java/test/__init__.py b/src/python/pants/backend/java/test/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/python/pants/backend/java/test/junit.py b/src/python/pants/backend/java/test/junit.py new file mode 100644 index 00000000000..0219094d7ec --- /dev/null +++ b/src/python/pants/backend/java/test/junit.py @@ -0,0 +1,125 @@ +# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md). +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +import logging +from dataclasses import dataclass + +from pants.backend.java.compile.javac import CompiledClassfiles, CompileJavaSourceRequest +from pants.backend.java.target_types import JavaTestsSources +from pants.core.goals.test import TestFieldSet, TestResult +from pants.engine.addresses import Addresses +from pants.engine.fs import AddPrefix, Digest, MergeDigests +from pants.engine.process import FallibleProcessResult, Process +from pants.engine.rules import Get, MultiGet, collect_rules, rule +from pants.engine.target import ( + CoarsenedTargets, + Targets, + TransitiveTargets, + TransitiveTargetsRequest, +) +from pants.engine.unions import UnionRule +from pants.jvm.resolve.coursier_fetch import ( + CoursierLockfileForTargetRequest, + CoursierResolvedLockfile, + MaterializedClasspath, + MaterializedClasspathRequest, + MavenRequirements, +) +from pants.jvm.resolve.coursier_setup import Coursier +from pants.util.logging import LogLevel + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class JavaTestFieldSet(TestFieldSet): + required_fields = (JavaTestsSources,) + + sources: JavaTestsSources + + +@rule(desc="Run JUnit", level=LogLevel.DEBUG) +async def run_junit_test( + coursier: Coursier, + field_set: JavaTestFieldSet, +) -> TestResult: + transitive_targets = await Get(TransitiveTargets, TransitiveTargetsRequest([field_set.address])) + coarsened_targets = await Get( + CoarsenedTargets, Addresses(t.address for t in transitive_targets.closure) + ) + lockfile = await Get( + CoursierResolvedLockfile, + CoursierLockfileForTargetRequest(Targets(transitive_targets.closure)), + ) + materialized_classpath = await Get( + MaterializedClasspath, + MaterializedClasspathRequest( + prefix="__thirdpartycp", + lockfiles=(lockfile,), + maven_requirements=( + MavenRequirements.create_from_maven_coordinates_fields( + fields=(), + additional_requirements=[ + "org.junit.platform:junit-platform-console:1.7.2", + "org.junit.jupiter:junit-jupiter-engine:5.7.2", + "org.junit.vintage:junit-vintage-engine:5.7.2", + ], + ), + ), + ), + ) + transitive_user_classfiles = await MultiGet( + Get(CompiledClassfiles, CompileJavaSourceRequest(component=t)) for t in coarsened_targets + ) + merged_transitive_user_classfiles_digest = await Get( + Digest, MergeDigests(classfiles.digest for classfiles in transitive_user_classfiles) + ) + usercp_relpath = "__usercp" + prefixed_transitive_user_classfiles_digest = await Get( + Digest, AddPrefix(merged_transitive_user_classfiles_digest, usercp_relpath) + ) + merged_digest = await Get( + Digest, + MergeDigests( + ( + prefixed_transitive_user_classfiles_digest, + materialized_classpath.digest, + coursier.digest, + ) + ), + ) + proc = Process( + argv=[ + coursier.coursier.exe, + "java", + "--system-jvm", # TODO(#12293): use a fixed JDK version from a subsystem. + "-cp", + materialized_classpath.classpath_arg(), + "org.junit.platform.console.ConsoleLauncher", + "--classpath", + usercp_relpath, + "--scan-class-path", + usercp_relpath, + ], + input_digest=merged_digest, + description=f"Run JUnit 5 ConsoleLauncher against {field_set.address}", + level=LogLevel.DEBUG, + ) + + process_result = await Get( + FallibleProcessResult, + Process, + proc, + ) + + return TestResult.from_fallible_process_result( + process_result, + address=field_set.address, + ) + + +def rules(): + return [ + *collect_rules(), + UnionRule(TestFieldSet, JavaTestFieldSet), + ] diff --git a/src/python/pants/backend/java/test/junit_test.py b/src/python/pants/backend/java/test/junit_test.py new file mode 100644 index 00000000000..7add5e14423 --- /dev/null +++ b/src/python/pants/backend/java/test/junit_test.py @@ -0,0 +1,607 @@ +# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md). +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from __future__ import annotations + +import re +from textwrap import dedent + +import pytest + +from pants.backend.java.compile.javac import rules as javac_rules +from pants.backend.java.compile.javac_binary import rules as javac_binary_rules +from pants.backend.java.target_types import JavaLibrary, JunitTests +from pants.backend.java.test.junit import JavaTestFieldSet +from pants.backend.java.test.junit import rules as junit_rules +from pants.build_graph.address import Address +from pants.core.goals.test import TestResult +from pants.core.util_rules import config_files, source_files +from pants.core.util_rules.external_tool import rules as external_tool_rules +from pants.engine.addresses import Addresses +from pants.engine.fs import FileDigest +from pants.engine.target import CoarsenedTargets +from pants.jvm.resolve.coursier_fetch import ( + CoursierLockfileEntry, + CoursierResolvedLockfile, + MavenCoord, + MavenCoordinates, +) +from pants.jvm.resolve.coursier_fetch import rules as coursier_fetch_rules +from pants.jvm.resolve.coursier_setup import rules as coursier_setup_rules +from pants.jvm.target_types import JvmDependencyLockfile +from pants.jvm.util_rules import rules as util_rules +from pants.testutil.rule_runner import QueryRule, RuleRunner + + +@pytest.fixture +def rule_runner() -> RuleRunner: + return RuleRunner( + preserve_tmpdirs=True, + rules=[ + *config_files.rules(), + *coursier_fetch_rules(), + *coursier_setup_rules(), + *external_tool_rules(), + *source_files.rules(), + *javac_rules(), + *junit_rules(), + *javac_binary_rules(), + *util_rules(), + QueryRule(CoarsenedTargets, (Addresses,)), + QueryRule(TestResult, (JavaTestFieldSet,)), + ], + target_types=[JvmDependencyLockfile, JavaLibrary, JunitTests], + bootstrap_args=["--javac-jdk=system"], # TODO(#12293): use a fixed JDK version. + ) + + +# This is hard-coded to make the test somewhat more hermetic. +# To regenerate (e.g. to update the resolved version), run the +# following in a test: +# resolved_lockfile = rule_runner.request( +# CoursierResolvedLockfile, +# [ +# MavenRequirements.create_from_maven_coordinates_fields( +# fields=(), +# additional_requirements=["junit:junit:4.13.2"], +# ) +# ], +# ) +# The `repr` of the resulting lockfile object can be directly copied +# into code to get the following: +JUNIT4_RESOLVED_LOCKFILE = CoursierResolvedLockfile( + entries=( + CoursierLockfileEntry( + coord=MavenCoord(coord="junit:junit:4.13.2"), + file_name="junit-4.13.2.jar", + direct_dependencies=MavenCoordinates( + [MavenCoord(coord="org.hamcrest:hamcrest-core:1.3")] + ), + dependencies=MavenCoordinates([MavenCoord(coord="org.hamcrest:hamcrest-core:1.3")]), + file_digest=FileDigest( + fingerprint="8e495b634469d64fb8acfa3495a065cbacc8a0fff55ce1e31007be4c16dc57d3", + serialized_bytes_length=384581, + ), + ), + CoursierLockfileEntry( + coord=MavenCoord(coord="org.hamcrest:hamcrest-core:1.3"), + file_name="hamcrest-core-1.3.jar", + direct_dependencies=MavenCoordinates([]), + dependencies=MavenCoordinates([]), + file_digest=FileDigest( + fingerprint="66fdef91e9739348df7a096aa384a5685f4e875584cce89386a7a47251c4d8e9", + serialized_bytes_length=45024, + ), + ), + ) +) + + +def test_vintage_simple_success(rule_runner: RuleRunner) -> None: + rule_runner.write_files( + { + "coursier_resolve.lockfile": JUNIT4_RESOLVED_LOCKFILE.to_json().decode("utf-8"), + "BUILD": dedent( + """\ + coursier_lockfile( + name = 'lockfile', + maven_requirements = ['junit:junit:4.13.2'], + sources = [ + "coursier_resolve.lockfile", + ], + ) + + junit_tests( + name='example-test', + dependencies= [':lockfile'], + ) + """ + ), + "SimpleTest.java": dedent( + """ + package org.pantsbuild.example; + + import junit.framework.TestCase; + + public class SimpleTest extends TestCase { + public void testHello(){ + assertTrue("Hello!" == "Hello!"); + } + } + """ + ), + } + ) + + test_result = rule_runner.request( + TestResult, + [ + JavaTestFieldSet.create( + rule_runner.get_target(address=Address(spec_path="", target_name="example-test")) + ), + ], + ) + assert test_result.exit_code == 0 + assert re.search(r"testHello.*?\[OK\]", test_result.stdout) is not None + assert re.search(r"1 tests successful", test_result.stdout) is not None + assert re.search(r"1 tests found", test_result.stdout) is not None + + +def test_vintage_simple_failure(rule_runner: RuleRunner) -> None: + rule_runner.write_files( + { + "coursier_resolve.lockfile": JUNIT4_RESOLVED_LOCKFILE.to_json().decode("utf-8"), + "BUILD": dedent( + """\ + coursier_lockfile( + name = 'lockfile', + maven_requirements = ['junit:junit:4.13.2'], + sources = [ + "coursier_resolve.lockfile", + ], + ) + + junit_tests( + name='example-test', + dependencies= [':lockfile'], + ) + """ + ), + "SimpleTest.java": dedent( + """ + package org.pantsbuild.example; + + import org.junit.Test; + import static org.junit.Assert.*; + + public class SimpleTest { + @Test + public void helloTest(){ + assertTrue("Goodbye!" == "Hello!"); + } + } + """ + ), + } + ) + + test_result = rule_runner.request( + TestResult, + [ + JavaTestFieldSet.create( + rule_runner.get_target(address=Address(spec_path="", target_name="example-test")) + ) + ], + ) + assert test_result.exit_code == 1 + assert ( + re.search(r"helloTest.*?\[X\].*?java.lang.AssertionError", test_result.stdout) is not None + ) + assert re.search(r"1 tests failed", test_result.stdout) is not None + assert re.search(r"1 tests found", test_result.stdout) is not None + + +def test_vintage_success_with_dep(rule_runner: RuleRunner) -> None: + rule_runner.write_files( + { + "coursier_resolve.lockfile": JUNIT4_RESOLVED_LOCKFILE.to_json().decode("utf-8"), + "BUILD": dedent( + """\ + coursier_lockfile( + name = 'lockfile', + maven_requirements = ['junit:junit:4.13.2'], + sources = [ + "coursier_resolve.lockfile", + ], + ) + + java_library( + name='example-lib', + dependencies = [ + ':lockfile', ], + ) + + junit_tests( + name = 'example-test', + dependencies = [ + ':lockfile', + '//:example-lib', + ], + ) + """ + ), + "ExampleLib.java": dedent( + """ + package org.pantsbuild.example.lib; + + public class ExampleLib { + public static String hello() { + return "Hello!"; + } + } + """ + ), + "ExampleTest.java": dedent( + """ + package org.pantsbuild.example; + + import org.pantsbuild.example.lib.ExampleLib; + import junit.framework.TestCase; + + public class ExampleTest extends TestCase { + public void testHello(){ + assertTrue(ExampleLib.hello() == "Hello!"); + } + } + """ + ), + } + ) + + test_result = rule_runner.request( + TestResult, + [ + JavaTestFieldSet.create( + rule_runner.get_target(address=Address(spec_path="", target_name="example-test")) + ), + ], + ) + assert test_result.exit_code == 0 + assert re.search(r"testHello.*?\[OK\]", test_result.stdout) is not None + assert re.search(r"1 tests successful", test_result.stdout) is not None + assert re.search(r"1 tests found", test_result.stdout) is not None + + +# This is hard-coded to make the test somewhat more hermetic. +# To regenerate (e.g. to update the resolved version), run the +# following in a test: +# resolved_lockfile = rule_runner.request( +# CoursierResolvedLockfile, +# [ +# MavenRequirements.create_from_maven_coordinates_fields( +# fields=(), +# additional_requirements=["org.junit.jupiter:junit-jupiter-api:5.7.2"], +# ) +# ], +# ) +# The `repr` of the resulting lockfile object can be directly copied +# into code to get the following: +JUNIT5_RESOLVED_LOCKFILE = CoursierResolvedLockfile( + entries=( + CoursierLockfileEntry( + coord=MavenCoord(coord="org.apiguardian:apiguardian-api:1.1.0"), + file_name="apiguardian-api-1.1.0.jar", + direct_dependencies=MavenCoordinates([]), + dependencies=MavenCoordinates([]), + file_digest=FileDigest( + fingerprint="a9aae9ff8ae3e17a2a18f79175e82b16267c246fbbd3ca9dfbbb290b08dcfdd4", + serialized_bytes_length=2387, + ), + ), + CoursierLockfileEntry( + coord=MavenCoord(coord="org.junit.jupiter:junit-jupiter-api:5.7.2"), + file_name="junit-jupiter-api-5.7.2.jar", + direct_dependencies=MavenCoordinates( + [ + MavenCoord(coord="org.apiguardian:apiguardian-api:1.1.0"), + MavenCoord(coord="org.junit.platform:junit-platform-commons:1.7.2"), + MavenCoord(coord="org.opentest4j:opentest4j:1.2.0"), + ] + ), + dependencies=MavenCoordinates( + [ + MavenCoord(coord="org.apiguardian:apiguardian-api:1.1.0"), + MavenCoord(coord="org.junit.platform:junit-platform-commons:1.7.2"), + MavenCoord(coord="org.opentest4j:opentest4j:1.2.0"), + ] + ), + file_digest=FileDigest( + fingerprint="bc98326ecbc501e1860a2bc9780aebe5777bd29cf00059f88c2a56f48fbc9ce6", + serialized_bytes_length=175588, + ), + ), + CoursierLockfileEntry( + coord=MavenCoord(coord="org.junit.platform:junit-platform-commons:1.7.2"), + file_name="junit-platform-commons-1.7.2.jar", + direct_dependencies=MavenCoordinates( + [MavenCoord(coord="org.apiguardian:apiguardian-api:1.1.0")] + ), + dependencies=MavenCoordinates( + [MavenCoord(coord="org.apiguardian:apiguardian-api:1.1.0")] + ), + file_digest=FileDigest( + fingerprint="738d0df021a0611fff5d277634e890cc91858fa72227cf0bcf36232a7caf014c", + serialized_bytes_length=100008, + ), + ), + CoursierLockfileEntry( + coord=MavenCoord(coord="org.opentest4j:opentest4j:1.2.0"), + file_name="opentest4j-1.2.0.jar", + direct_dependencies=MavenCoordinates([]), + dependencies=MavenCoordinates([]), + file_digest=FileDigest( + fingerprint="58812de60898d976fb81ef3b62da05c6604c18fd4a249f5044282479fc286af2", + serialized_bytes_length=7653, + ), + ), + ) +) + + +def test_jupiter_simple_success(rule_runner: RuleRunner) -> None: + rule_runner.write_files( + { + "coursier_resolve.lockfile": JUNIT5_RESOLVED_LOCKFILE.to_json().decode("utf-8"), + "BUILD": dedent( + """\ + coursier_lockfile( + name = 'lockfile', + maven_requirements = [ + 'org.junit.jupiter:junit-jupiter-api:5.7.2', + ], + sources = [ + "coursier_resolve.lockfile", + ], + ) + + junit_tests( + name='example-test', + dependencies= [':lockfile'], + ) + """ + ), + "SimpleTest.java": dedent( + """ + package org.pantsbuild.example; + + import static org.junit.jupiter.api.Assertions.assertEquals; + import org.junit.jupiter.api.Test; + + class SimpleTests { + @Test + void testHello(){ + assertEquals("Hello!", "Hello!"); + } + } + """ + ), + } + ) + + test_result = rule_runner.request( + TestResult, + [ + JavaTestFieldSet.create( + rule_runner.get_target(address=Address(spec_path="", target_name="example-test")) + ), + ], + ) + assert test_result.exit_code == 0 + assert re.search(r"testHello.*?\[OK\]", test_result.stdout) is not None + assert re.search(r"1 tests successful", test_result.stdout) is not None + assert re.search(r"1 tests found", test_result.stdout) is not None + + +def test_jupiter_simple_failure(rule_runner: RuleRunner) -> None: + rule_runner.write_files( + { + "coursier_resolve.lockfile": JUNIT5_RESOLVED_LOCKFILE.to_json().decode("utf-8"), + "BUILD": dedent( + """\ + coursier_lockfile( + name = 'lockfile', + maven_requirements = [ + 'org.junit.jupiter:junit-jupiter-api:5.7.2', + ], + sources = [ + "coursier_resolve.lockfile", + ], + ) + + junit_tests( + name='example-test', + dependencies= [':lockfile'], + ) + """ + ), + "SimpleTest.java": dedent( + """ + package org.pantsbuild.example; + + import static org.junit.jupiter.api.Assertions.assertEquals; + import org.junit.jupiter.api.Test; + + class SimpleTest { + @Test + void testHello(){ + assertEquals("Goodbye!", "Hello!"); + } + } + """ + ), + } + ) + + test_result = rule_runner.request( + TestResult, + [ + JavaTestFieldSet.create( + rule_runner.get_target(address=Address(spec_path="", target_name="example-test")) + ) + ], + ) + assert test_result.exit_code == 1 + assert ( + re.search( + r"testHello\(\).*?\[X\].*?expected: but was: ", test_result.stdout + ) + is not None + ) + assert re.search(r"1 tests failed", test_result.stdout) is not None + assert re.search(r"1 tests found", test_result.stdout) is not None + + +def test_jupiter_success_with_dep(rule_runner: RuleRunner) -> None: + rule_runner.write_files( + { + "coursier_resolve.lockfile": JUNIT5_RESOLVED_LOCKFILE.to_json().decode("utf-8"), + "BUILD": dedent( + """\ + coursier_lockfile( + name = 'lockfile', + maven_requirements = ['org.junit.jupiter:junit-jupiter-api:5.7.2'], + sources = [ + "coursier_resolve.lockfile", + ], + ) + + java_library( + name='example-lib', + dependencies = [ + ':lockfile', + ], + ) + + junit_tests( + name = 'example-test', + dependencies = [ + ':lockfile', + '//:example-lib', + ], + ) + """ + ), + "ExampleLib.java": dedent( + """ + package org.pantsbuild.example.lib; + + public class ExampleLib { + public static String hello() { + return "Hello!"; + } + } + """ + ), + "SimpleTest.java": dedent( + """ + package org.pantsbuild.example; + + import static org.junit.jupiter.api.Assertions.assertEquals; + import org.junit.jupiter.api.Test; + import org.pantsbuild.example.lib.ExampleLib; + + class SimpleTest { + @Test + void testHello(){ + assertEquals(ExampleLib.hello(), "Hello!"); + } + } + """ + ), + } + ) + + test_result = rule_runner.request( + TestResult, + [ + JavaTestFieldSet.create( + rule_runner.get_target(address=Address(spec_path="", target_name="example-test")) + ), + ], + ) + assert test_result.exit_code == 0 + assert re.search(r"testHello.*?\[OK\]", test_result.stdout) is not None + assert re.search(r"1 tests successful", test_result.stdout) is not None + assert re.search(r"1 tests found", test_result.stdout) is not None + + +def test_vintage_and_jupiter_simple_success(rule_runner: RuleRunner) -> None: + combined_lockfile = CoursierResolvedLockfile( + entries=(*JUNIT4_RESOLVED_LOCKFILE.entries, *JUNIT5_RESOLVED_LOCKFILE.entries) + ) + rule_runner.write_files( + { + "coursier_resolve.lockfile": combined_lockfile.to_json().decode("utf-8"), + "BUILD": dedent( + """\ + coursier_lockfile( + name = 'lockfile', + maven_requirements = [ + 'junit:junit:4.13.2', + 'org.junit.jupiter:junit-jupiter-api:5.7.2', + ], + sources = [ + "coursier_resolve.lockfile", + ], + ) + + junit_tests( + name='example-test', + dependencies= [':lockfile'], + ) + """ + ), + "JupiterTest.java": dedent( + """ + package org.pantsbuild.example; + + import static org.junit.jupiter.api.Assertions.assertEquals; + import org.junit.jupiter.api.Test; + + class JupiterTest { + @Test + void testHello(){ + assertEquals("Hello!", "Hello!"); + } + } + """ + ), + "VintageTest.java": dedent( + """ + package org.pantsbuild.example; + + import junit.framework.TestCase; + + public class VintageTest extends TestCase { + public void testGoodbye(){ + assertTrue("Hello!" == "Hello!"); + } + } + """ + ), + } + ) + + test_result = rule_runner.request( + TestResult, + [ + JavaTestFieldSet.create( + rule_runner.get_target(address=Address(spec_path="", target_name="example-test")) + ), + ], + ) + assert test_result.exit_code == 0 + assert re.search(r"testHello.*?\[OK\]", test_result.stdout) is not None + assert re.search(r"testGoodbye.*?\[OK\]", test_result.stdout) is not None + assert re.search(r"2 tests successful", test_result.stdout) is not None + assert re.search(r"2 tests found", test_result.stdout) is not None