Skip to content

Commit 46030fe

Browse files
committed
Implement JctExtension JUnit extension.
This enables declaring workspaces on a class level as fields and annotating them with the 'Managed' annotation to enable automatic creation and closure of workspaces as part of the JUnit workflow, leading to cleaner declarative test code.
1 parent 6ee7956 commit 46030fe

File tree

12 files changed

+1073
-82
lines changed

12 files changed

+1073
-82
lines changed

README.md

Lines changed: 66 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -138,45 +138,47 @@ open module my.tests {
138138
```java
139139

140140
@DisplayName("Example tests")
141+
@ExtendWith(JctExtension.class)
141142
class ExampleTest {
143+
144+
@Managed
145+
Workspace workspace;
142146

143147
@DisplayName("I can compile a Hello World application")
144148
@JavacCompilerTest
145149
void canCompileHelloWorld(JctCompiler<?, ?> compiler) {
146-
try (var workspace = Workspaces.newWorkspace()) {
147-
// Given
148-
workspace
149-
.createSourcePathPackage()
150-
.createFile("org/example/Message.java").withContents("""
151-
package org.example;
152-
153-
import lombok.Data;
154-
import lombok.NonNull;
155-
156-
@Data
157-
public class Message {
158-
private String content;
159-
160-
public static void main(String[] args) {
161-
Message message = new Message("Hello, World!");
162-
System.out.println(message);
163-
}
150+
// Given
151+
workspace
152+
.createSourcePathPackage()
153+
.createFile("org/example/Message.java").withContents("""
154+
package org.example;
155+
156+
import lombok.Data;
157+
import lombok.NonNull;
158+
159+
@Data
160+
public class Message {
161+
private String content;
162+
163+
public static void main(String[] args) {
164+
Message message = new Message("Hello, World!");
165+
System.out.println(message);
164166
}
165-
"""
166-
);
167+
}
168+
"""
169+
);
167170

168-
// When
169-
var compilation = compiler.compile(workspace);
171+
// When
172+
var compilation = compiler.compile(workspace);
170173

171-
// Then
172-
assertThatCompilation(compilation)
173-
.isSuccessfulWithoutWarnings();
174+
// Then
175+
assertThatCompilation(compilation)
176+
.isSuccessfulWithoutWarnings();
174177

175-
assertThatCompilation(compilation)
176-
.classOutput().packages()
177-
.fileExists("com/example/Message.class")
178-
.isNotEmptyFile();
179-
}
178+
assertThatCompilation(compilation)
179+
.classOutput().packages()
180+
.fileExists("com/example/Message.class")
181+
.isNotEmptyFile();
180182
}
181183
}
182184
```
@@ -192,42 +194,44 @@ import io.github.ascopes.jct.workspaces.Workspaces;
192194
import org.example.processor.JsonSchemaAnnotationProcessor;
193195
import org.skyscreamer.jsonassert.JSONAssert;
194196

197+
@ExtendWith(JctExtension.class)
195198
class JsonSchemaAnnotationProcessorTest {
199+
200+
@Managed
201+
Workspace workspace;
196202

197203
@JavacCompilerTest(minVersion = 11, maxVersion = 19)
198204
void theJsonSchemaIsCreatedFromTheInputCode(JctCompiler<?, ?> compiler) {
199-
try (var workspace = Workspaces.newWorkspace()) {
200-
// Given
201-
workspace
202-
.createSourcePathPackage()
203-
.createDirectory("org", "example", "tests")
204-
.copyContentsFrom("src", "test", "resources", "code", "schematest");
205-
206-
// When
207-
var compilation = compiler
208-
.addAnnotationProcessors(new JsonSchemaAnnotationProcessor())
209-
.addAnnotationProcessorOptions("jsonschema.verbose=true")
210-
.failOnWarnings(true)
211-
.showDeprecationWarnings(true)
212-
.compile(workspace);
213-
214-
// Then
215-
assertThatCompilation(compilation)
216-
.isSuccessfulWithoutWarnings();
217-
218-
assertThatCompilation(compilation)
219-
.diagnostics().notes().singleElement()
220-
.message().isEqualTo(
221-
"Creating JSON schema in Java %s for package org.example.tests",
222-
compiler.getRelease()
223-
);
224-
225-
assertThatCompilation(compilation)
226-
.classOutputs().packages()
227-
.fileExists("json-schemas", "UserSchema.json").contents()
228-
.isNotEmpty()
229-
.satisfies(contents -> JSONAssert.assertEquals(...));
230-
}
205+
// Given
206+
workspace
207+
.createSourcePathPackage()
208+
.createDirectory("org", "example", "tests")
209+
.copyContentsFrom("src", "test", "resources", "code", "schematest");
210+
211+
// When
212+
var compilation = compiler
213+
.addAnnotationProcessors(new JsonSchemaAnnotationProcessor())
214+
.addAnnotationProcessorOptions("jsonschema.verbose=true")
215+
.failOnWarnings(true)
216+
.showDeprecationWarnings(true)
217+
.compile(workspace);
218+
219+
// Then
220+
assertThatCompilation(compilation)
221+
.isSuccessfulWithoutWarnings();
222+
223+
assertThatCompilation(compilation)
224+
.diagnostics().notes().singleElement()
225+
.message().isEqualTo(
226+
"Creating JSON schema in Java %s for package org.example.tests",
227+
compiler.getRelease()
228+
);
229+
230+
assertThatCompilation(compilation)
231+
.classOutputs().packages()
232+
.fileExists("json-schemas", "UserSchema.json").contents()
233+
.isNotEmpty()
234+
.satisfies(contents -> JSONAssert.assertEquals(...));
231235
}
232236
}
233237
```

acceptance-tests/acceptance-tests-dogfood/pom.xml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,30 @@
4343
<scope>test</scope>
4444
</dependency>
4545

46+
<dependency>
47+
<groupId>org.junit.platform</groupId>
48+
<artifactId>junit-platform-commons</artifactId>
49+
<scope>test</scope>
50+
</dependency>
51+
52+
<dependency>
53+
<groupId>org.junit.platform</groupId>
54+
<artifactId>junit-platform-engine</artifactId>
55+
<scope>test</scope>
56+
</dependency>
57+
58+
<dependency>
59+
<groupId>org.junit.platform</groupId>
60+
<artifactId>junit-platform-launcher</artifactId>
61+
<scope>test</scope>
62+
</dependency>
63+
64+
<dependency>
65+
<groupId>org.junit.platform</groupId>
66+
<artifactId>junit-platform-testkit</artifactId>
67+
<scope>test</scope>
68+
</dependency>
69+
4670
<dependency>
4771
<groupId>org.slf4j</groupId>
4872
<artifactId>slf4j-simple</artifactId>

acceptance-tests/acceptance-tests-dogfood/src/test/java/module-info.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
requires transitive org.junit.jupiter.api;
2121
requires transitive org.junit.jupiter.engine;
2222
requires transitive org.junit.jupiter.params;
23-
requires transitive org.junit.platform.commons; // required to make IntelliJ happy.
24-
requires transitive org.junit.platform.engine; // required to make IntelliJ happy.
23+
requires transitive org.junit.platform.commons; // required to make IntelliJ happy.
24+
requires transitive org.junit.platform.engine; // required to make IntelliJ happy.
25+
requires transitive org.junit.platform.launcher; // required to make IntelliJ happy.
2526
}

java-compiler-testing/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@
9090
<scope>test</scope>
9191
</dependency>
9292

93+
<dependency>
94+
<groupId>org.junit.platform</groupId>
95+
<artifactId>junit-platform-testkit</artifactId>
96+
<scope>test</scope>
97+
</dependency>
98+
9399
<dependency>
94100
<groupId>org.junit.jupiter</groupId>
95101
<artifactId>junit-jupiter</artifactId>
@@ -138,6 +144,7 @@
138144
<excludePackageNames>
139145
io.github.ascopes.jct.compilers.javac;
140146
io.github.ascopes.jct.**.impl;
147+
io.github.ascopes.jct.junit.ext;
141148
io.github.ascopes.jct.utils;
142149
</excludePackageNames>
143150
</configuration>
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* Copyright (C) 2022 - 2023, the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.github.ascopes.jct.junit;
17+
18+
import io.github.ascopes.jct.workspaces.Workspace;
19+
import io.github.ascopes.jct.workspaces.Workspaces;
20+
import java.lang.reflect.Field;
21+
import java.lang.reflect.Modifier;
22+
import java.util.ArrayList;
23+
import java.util.List;
24+
import org.apiguardian.api.API;
25+
import org.apiguardian.api.API.Status;
26+
import org.jspecify.annotations.Nullable;
27+
import org.junit.jupiter.api.extension.AfterAllCallback;
28+
import org.junit.jupiter.api.extension.AfterEachCallback;
29+
import org.junit.jupiter.api.extension.BeforeAllCallback;
30+
import org.junit.jupiter.api.extension.BeforeEachCallback;
31+
import org.junit.jupiter.api.extension.Extension;
32+
import org.junit.jupiter.api.extension.ExtensionContext;
33+
import org.slf4j.Logger;
34+
import org.slf4j.LoggerFactory;
35+
36+
/**
37+
* Implicit extension that will manage the lifecycle of {@link Managed}-annotated {@link Workspace}
38+
* fields within JUnit5 test classes.
39+
*
40+
* @author Ashley Scopes
41+
* @since 0.4.0
42+
*/
43+
@API(since = "0.4.0", status = Status.STABLE)
44+
public final class JctExtension implements
45+
Extension, BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback {
46+
47+
private static final Logger LOGGER = LoggerFactory.getLogger(JctExtension.class);
48+
49+
@Override
50+
public void beforeAll(ExtensionContext context) throws Exception {
51+
for (var field : getManagedStaticWorkspaceFields(context.getRequiredTestClass())) {
52+
initWorkspaceForField(field, null);
53+
}
54+
}
55+
56+
@Override
57+
public void beforeEach(ExtensionContext context) throws Exception {
58+
for (var instance : context.getRequiredTestInstances().getAllInstances()) {
59+
for (var field : getManagedInstanceWorkspaceFields(instance.getClass())) {
60+
initWorkspaceForField(field, instance);
61+
}
62+
}
63+
}
64+
65+
@Override
66+
public void afterAll(ExtensionContext context) throws Exception {
67+
for (var field : getManagedStaticWorkspaceFields(context.getRequiredTestClass())) {
68+
closeWorkspaceForField(field, null);
69+
}
70+
}
71+
72+
@Override
73+
public void afterEach(ExtensionContext context) throws Exception {
74+
for (var instance : context.getRequiredTestInstances().getAllInstances()) {
75+
for (var field : getManagedInstanceWorkspaceFields(instance.getClass())) {
76+
closeWorkspaceForField(field, instance);
77+
}
78+
}
79+
}
80+
81+
private List<Field> getManagedStaticWorkspaceFields(Class<?> clazz) {
82+
// Do not recurse for static fields, as the state of any parent classes may be shared
83+
// with other classes running in parallel. Need to look up how JUnit expects us to handle that
84+
// case, if at all.
85+
86+
var fields = new ArrayList<Field>();
87+
88+
for (var field : clazz.getDeclaredFields()) {
89+
if (isWorkspaceField(field) && Modifier.isStatic(field.getModifiers())) {
90+
fields.add(field);
91+
}
92+
}
93+
94+
return fields;
95+
}
96+
97+
private List<Field> getManagedInstanceWorkspaceFields(Class<?> clazz) {
98+
// For instances, discover all the fields recursively in superclasses as well that are
99+
// non-static.
100+
101+
var fields = new ArrayList<Field>();
102+
103+
while (clazz != null) {
104+
for (var field : clazz.getDeclaredFields()) {
105+
if (isWorkspaceField(field) && !Modifier.isStatic(field.getModifiers())) {
106+
fields.add(field);
107+
}
108+
}
109+
clazz = clazz.getSuperclass();
110+
}
111+
112+
return fields;
113+
}
114+
115+
private boolean isWorkspaceField(Field field) {
116+
return field.getType().equals(Workspace.class)
117+
&& field.isAnnotationPresent(Managed.class);
118+
}
119+
120+
private void initWorkspaceForField(Field field, @Nullable Object instance) throws Exception {
121+
LOGGER
122+
.atTrace()
123+
.setMessage("Initialising workspace for field in {}: {} {} on instance {}")
124+
.addArgument(() -> field.getDeclaringClass().getSimpleName())
125+
.addArgument(() -> field.getType().getSimpleName())
126+
.addArgument(field::getName)
127+
.addArgument(instance)
128+
.log();
129+
130+
field.setAccessible(true);
131+
var managedWorkspace = field.getAnnotation(Managed.class);
132+
var workspace = Workspaces.newWorkspace(managedWorkspace.pathStrategy());
133+
field.set(instance, workspace);
134+
}
135+
136+
private void closeWorkspaceForField(Field field, @Nullable Object instance) throws Exception {
137+
LOGGER
138+
.atTrace()
139+
.setMessage("Closing workspace for field in {}: {} {} on instance {}")
140+
.addArgument(() -> field.getDeclaringClass().getSimpleName())
141+
.addArgument(() -> field.getType().getSimpleName())
142+
.addArgument(field::getName)
143+
.addArgument(instance)
144+
.log();
145+
146+
field.setAccessible(true);
147+
((Workspace) field.get(instance)).close();
148+
}
149+
}

0 commit comments

Comments
 (0)