Skip to content

Commit

Permalink
issue/9: improve unit test coverage for skippy/skippy-core (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
fmck3516 authored Nov 27, 2023
1 parent df769c8 commit 5fb055e
Show file tree
Hide file tree
Showing 17 changed files with 375 additions and 71 deletions.
3 changes: 3 additions & 0 deletions skippy-core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ repositories {
dependencies {
implementation 'org.apache.logging.log4j:log4j-api:2.20.0'
implementation 'org.apache.logging.log4j:log4j-core:2.20.0'

testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
testImplementation 'org.mockito:mockito-core:5.4.0'

testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}

Expand Down
29 changes: 1 addition & 28 deletions skippy-core/src/main/java/io/skippy/core/AnalyzedFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,10 @@
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;

import static java.util.Collections.emptyList;

/**
* A file that has been analyzed by Skippy:
Expand Down Expand Up @@ -42,36 +37,14 @@ class AnalyzedFile {
* @param sourceFileHash the MD5 hash of the content of the source file (e.g., pz1sZJBt6JArm/LYs+UXKg==)
* @param classFileHash the MD5 hash of the content of the class file (e.g., YA9ExftvTDku3TUNsbkWIw==)
*/
private AnalyzedFile(FullyQualifiedClassName fullyQualifiedClassName, Path sourceFile, Path classFile, String sourceFileHash, String classFileHash) {
AnalyzedFile(FullyQualifiedClassName fullyQualifiedClassName, Path sourceFile, Path classFile, String sourceFileHash, String classFileHash) {
this.fullyQualifiedClassName = fullyQualifiedClassName;
this.sourceFile = sourceFile;
this.classFile = classFile;
this.sourceFileHash = sourceFileHash;
this.classFileHash = classFileHash;
}

static List<AnalyzedFile> parse(Path skippyAnalysisFile) {
if (!skippyAnalysisFile.toFile().exists()) {
return emptyList();
}
try {
var result = new ArrayList<AnalyzedFile>();
for (var line : Files.readAllLines(skippyAnalysisFile, Charset.forName("UTF8"))) {
String[] split = line.split(":");
result.add(new AnalyzedFile(new FullyQualifiedClassName(split[0]), Path.of(split[1]), Path.of(split[2]), split[3], split[4]));
}
return result;
} catch (Exception e) {
LOGGER.error("Parsing of file '%s' failed: '%s'".formatted(skippyAnalysisFile, e.getMessage()), e);
throw new RuntimeException(e);
}
}

/**
* Returns the fully-qualified class name.
*
* @return the fully-qualified class name
*/
FullyQualifiedClassName getFullyQualifiedClassName() {
return fullyQualifiedClassName;
}
Expand Down
71 changes: 71 additions & 0 deletions skippy-core/src/main/java/io/skippy/core/AnalyzedFileList.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package io.skippy.core;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

import static java.util.Collections.emptyList;

/**
* A list of {@link AnalyzedFile}s with a couple of utility methods that operates on this list.
*/
class AnalyzedFileList {

private static final Logger LOGGER = LogManager.getLogger(AnalyzedFileList.class);

static final AnalyzedFileList UNAVAILABLE = new AnalyzedFileList(emptyList());

private final List<AnalyzedFile> analyzedFiles;

/**
* C'tor.
*
* @param analyzedFiles a list of {@link AnalyzedFile}s
*/
private AnalyzedFileList(List<AnalyzedFile> analyzedFiles) {
this.analyzedFiles = analyzedFiles;
}

static AnalyzedFileList parse(Path skippyAnalysisFile) {
if (!skippyAnalysisFile.toFile().exists()) {
return UNAVAILABLE;
}
try {
var result = new ArrayList<AnalyzedFile>();
for (var line : Files.readAllLines(skippyAnalysisFile, Charset.forName("UTF8"))) {
String[] split = line.split(":");
result.add(new AnalyzedFile(new FullyQualifiedClassName(split[0]), Path.of(split[1]), Path.of(split[2]), split[3], split[4]));
}
return new AnalyzedFileList(result);
} catch (Exception e) {
LOGGER.error("Parsing of file '%s' failed: '%s'".formatted(skippyAnalysisFile, e.getMessage()), e);
throw new RuntimeException(e);
}
}

List<FullyQualifiedClassName> getClasses() {
return analyzedFiles.stream()
.map(s -> s.getFullyQualifiedClassName())
.toList();
}

List<FullyQualifiedClassName> getClassesWithSourceChanges() {
return analyzedFiles.stream()
.filter(s -> s.sourceFileHasChanged())
.map(s -> s.getFullyQualifiedClassName())
.toList();
}

List<FullyQualifiedClassName> getClassesWithBytecodeChanges() {
return analyzedFiles.stream()
.filter(s -> s.classFileHasChanged())
.map(s -> s.getFullyQualifiedClassName())
.toList();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
* Allows for more meaningful typing, e.g., {@code Map<FullyQualifiedClassName, List<FullyQualifiedClassName>>}
* instead of {@code Map<String, List<String>>}.
*/
record FullyQualifiedClassName(String fullyQualifiedClassName) {}
record FullyQualifiedClassName(String fqn) {}
66 changes: 24 additions & 42 deletions skippy-core/src/main/java/io/skippy/core/SkippyAnalysis.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.util.List;

import static java.util.Collections.emptyList;
import java.nio.file.Path;

/**
* The result of a Skippy analysis:
Expand All @@ -18,9 +16,9 @@ public class SkippyAnalysis {

private static final Logger LOGGER = LogManager.getLogger(SkippyAnalysis.class);

private static final SkippyAnalysis UNAVAILABLE = new SkippyAnalysis(emptyList(), TestImpactAnalysis.UNAVAILABLE);
private static final SkippyAnalysis UNAVAILABLE = new SkippyAnalysis(AnalyzedFileList.UNAVAILABLE, TestImpactAnalysis.UNAVAILABLE);

private final List<AnalyzedFile> analyzedFiles;
private final AnalyzedFileList analyzedFiles;
private final TestImpactAnalysis testImpactAnalysis;

/**
Expand All @@ -29,7 +27,7 @@ public class SkippyAnalysis {
* @param analyzedFiles all files that have been analyzed by Skippy's analysis
* @param testImpactAnalysis the {@link TestImpactAnalysis} created by Skippy's analysis
*/
private SkippyAnalysis(List<AnalyzedFile> analyzedFiles, TestImpactAnalysis testImpactAnalysis) {
SkippyAnalysis(AnalyzedFileList analyzedFiles, TestImpactAnalysis testImpactAnalysis) {
this.analyzedFiles = analyzedFiles;
this.testImpactAnalysis = testImpactAnalysis;
}
Expand All @@ -40,12 +38,16 @@ private SkippyAnalysis(List<AnalyzedFile> analyzedFiles, TestImpactAnalysis test
* @return a {@link SkippyAnalysis}
*/
public static SkippyAnalysis parse() {
if ( ! SkippyConstants.SKIPPY_DIRECTORY.toFile().exists() || ! SkippyConstants.SKIPPY_DIRECTORY.toFile().isDirectory()) {
return parse(SkippyConstants.SKIPPY_DIRECTORY);
}

static SkippyAnalysis parse(Path skippyDirectory) {
if ( ! skippyDirectory.toFile().exists() || ! skippyDirectory.toFile().isDirectory()) {
return SkippyAnalysis.UNAVAILABLE;
}
var sourceFileSnapshots = AnalyzedFile.parse(SkippyConstants.SKIPPY_DIRECTORY.resolve(SkippyConstants.SKIPPY_ANALYSIS_FILE));
var testCoverage = TestImpactAnalysis.parse(SkippyConstants.SKIPPY_DIRECTORY);
return new SkippyAnalysis(sourceFileSnapshots, testCoverage);
var analyzedFiles = AnalyzedFileList.parse(skippyDirectory.resolve(SkippyConstants.SKIPPY_ANALYSIS_FILE));
var testCoverage = TestImpactAnalysis.parse(skippyDirectory);
return new SkippyAnalysis(analyzedFiles, testCoverage);
}

/**
Expand All @@ -57,66 +59,46 @@ public static SkippyAnalysis parse() {
public boolean executionRequired(Class<?> test) {
var testFqn = new FullyQualifiedClassName(test.getName());
if (testImpactAnalysis.noDataAvailableFor(testFqn)) {
LOGGER.debug("%s: No analysis found. Execution required.".formatted(testFqn.fullyQualifiedClassName()));
LOGGER.debug("%s: No analysis found. Execution required.".formatted(testFqn.fqn()));
return true;
}
if (getClassesWithSourceChanges().contains(testFqn)) {
LOGGER.debug("%s: Source change detected. Execution required.".formatted(
testFqn.fullyQualifiedClassName()
));
if (analyzedFiles.getClassesWithSourceChanges().contains(testFqn)) {
LOGGER.debug("%s: Source change detected. Execution required.".formatted(testFqn.fqn()));
return true;
}
if (getClassesWithBytecodeChanges().contains(testFqn)) {
LOGGER.debug("%s: Bytecode change detected. Execution required.".formatted(
testFqn.fullyQualifiedClassName()
));
if (analyzedFiles.getClassesWithBytecodeChanges().contains(testFqn)) {
LOGGER.debug("%s: Bytecode change detected. Execution required.".formatted(testFqn.fqn()));
return true;
}
if (coveredClassHasChanged(testFqn)) {
return true;
}
LOGGER.debug("%s: No changes in test or covered classes detected. Execution skipped.".formatted(
testFqn.fullyQualifiedClassName()
));
LOGGER.debug("%s: No changes in test or covered classes detected. Execution skipped.".formatted(testFqn.fqn()));
return false;
}

private boolean coveredClassHasChanged(FullyQualifiedClassName test) {
var changedClassesWithSourceChanges = getClassesWithSourceChanges();
var changedClassesWithSourceChanges = analyzedFiles.getClassesWithSourceChanges();
for (var coveredClass : testImpactAnalysis.getCoveredClasses(test)) {
if (changedClassesWithSourceChanges.contains(coveredClass)) {
LOGGER.debug("%s: Source change in covered class '%s' detected. Execution required.".formatted(
test.fullyQualifiedClassName(),
coveredClass.fullyQualifiedClassName()
test.fqn(),
coveredClass.fqn()
));
return true;
}
}
var changedClassesWithBytecodeChanges = getClassesWithBytecodeChanges();
var changedClassesWithBytecodeChanges = analyzedFiles.getClassesWithBytecodeChanges();
for (var coveredClass : testImpactAnalysis.getCoveredClasses(test)) {
if (changedClassesWithBytecodeChanges.contains(coveredClass)) {
LOGGER.debug("%s: Bytecode change in covered class '%s' detected. Execution required.".formatted(
test.fullyQualifiedClassName(),
coveredClass.fullyQualifiedClassName()
test.fqn(),
coveredClass.fqn()
));
return true;
}
}
return false;
}

private List<FullyQualifiedClassName> getClassesWithSourceChanges() {
return analyzedFiles.stream()
.filter(s -> s.sourceFileHasChanged())
.map(s -> s.getFullyQualifiedClassName())
.toList();
}

private List<FullyQualifiedClassName> getClassesWithBytecodeChanges() {
return analyzedFiles.stream()
.filter(s -> s.classFileHasChanged())
.map(s -> s.getFullyQualifiedClassName())
.toList();
}

}
24 changes: 24 additions & 0 deletions skippy-core/src/test/java/io/skippy/core/AnalyzedFileListTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.skippy.core;

import org.junit.jupiter.api.Test;

import java.net.URISyntaxException;
import java.nio.file.Path;

import static java.util.Arrays.asList;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class AnalyzedFileListTest {

@Test
void testParse() throws URISyntaxException {
var analyzedFilesTxt = Path.of(getClass().getResource("analyzedfiles/analyzedFiles.txt").toURI());
var analyzedFiles = AnalyzedFileList.parse(analyzedFilesTxt);

assertEquals(asList(
new FullyQualifiedClassName("com.example.Foo"),
new FullyQualifiedClassName("com.example.FooTest")
), analyzedFiles.getClasses());
}

}
65 changes: 65 additions & 0 deletions skippy-core/src/test/java/io/skippy/core/AnalyzedFileTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package io.skippy.core;

import org.junit.jupiter.api.Test;

import java.net.URISyntaxException;
import java.nio.file.Path;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class AnalyzedFileTest {

@Test
void testSourceAndClassFileHaveNotChanged() throws URISyntaxException {
var foo = new AnalyzedFile(
new FullyQualifiedClassName("com.example.Foo"),
Path.of(getClass().getResource("analyzedfile/Foo.java").toURI()),
Path.of(getClass().getResource("analyzedfile/Foo.class").toURI()),
"cGN5C7g/BdD4rxxFBgZ7pw==",
"54Uq2W8MWDOi6dCDnWoLVQ=="
);

assertEquals(false, foo.sourceFileHasChanged());
assertEquals(false, foo.classFileHasChanged());
}

@Test
void testSourceFileHasChanged() throws URISyntaxException {
var fooNew = new AnalyzedFile(
new FullyQualifiedClassName("com.example.Foo"),
Path.of(getClass().getResource("analyzedfile/Foo.java").toURI()),
Path.of(getClass().getResource("analyzedfile/Foo.class").toURI()),
"NEW-SOURCE-FILE-HASH",
"54Uq2W8MWDOi6dCDnWoLVQ=="
);
assertEquals(true, fooNew.sourceFileHasChanged());
assertEquals(false, fooNew.classFileHasChanged());
}

@Test
void testClassFileHasChanged() throws URISyntaxException {
var fooNew = new AnalyzedFile(
new FullyQualifiedClassName("com.example.Foo"),
Path.of(getClass().getResource("analyzedfile/Foo.java").toURI()),
Path.of(getClass().getResource("analyzedfile/Foo.class").toURI()),
"cGN5C7g/BdD4rxxFBgZ7pw==",
"NEW-CLASS-FILE-HASH"
);
assertEquals(false, fooNew.sourceFileHasChanged());
assertEquals(true, fooNew.classFileHasChanged());
}

@Test
void testSourceAndClassFileHaveChanged() throws URISyntaxException {
var fooNew = new AnalyzedFile(
new FullyQualifiedClassName("com.example.Foo"),
Path.of(getClass().getResource("analyzedfile/Foo.java").toURI()),
Path.of(getClass().getResource("analyzedfile/Foo.class").toURI()),
"NEW-SOURCE-FILE-HASH",
"NEW-CLASS-FILE-HASH"
);
assertEquals(true, fooNew.sourceFileHasChanged());
assertEquals(true, fooNew.classFileHasChanged());
}

}
Loading

0 comments on commit 5fb055e

Please sign in to comment.