From 7f56ffeac22eec50ddbcec51c96f56108aec8499 Mon Sep 17 00:00:00 2001 From: Jonathan Bluett-Duncan Date: Mon, 14 Nov 2022 15:02:54 +0000 Subject: [PATCH] Create resource & temp dir extensions (#348 / #491) Introduces a general mechanism to inject resources into tests. These resources are either newly created for each test or shared across several tests - depending on which annotation was used. This change also introduces a specific resource that uses this mechanism, namely the temporary directory extension. Closes: #348 Relates to: #141 PR: #491 --- .gitignore | 3 + build.gradle.kts | 11 + docs/docs-nav.yml | 6 +- docs/resources.adoc | 364 +++++++ docs/temp-directory-removed.adoc | 61 ++ docs/temp-directory.adoc | 94 +- .../jupiter/resource/InMemoryDirectory.java | 81 ++ .../resource/ResourceExtensionDemo.java | 130 +++ .../jupiter/resource/package-info.java | 5 + .../junitpioneer/jupiter/package-info.java | 1 + .../junitpioneer/jupiter/resource/Dir.java | 38 + .../junitpioneer/jupiter/resource/New.java | 55 ++ .../jupiter/resource/PathDeleter.java | 40 + .../jupiter/resource/Resource.java | 51 + .../jupiter/resource/ResourceExtension.java | 483 +++++++++ .../jupiter/resource/ResourceFactory.java | 54 + .../junitpioneer/jupiter/resource/Shared.java | 92 ++ .../jupiter/resource/TemporaryDirectory.java | 80 ++ .../jupiter/resource/package-info.java | 20 + src/main/module/module-info.java | 1 + .../jupiter/resource/PathDeleterTests.java | 83 ++ .../resource/ResourcesParallelismTests.java | 516 ++++++++++ .../jupiter/resource/ResourcesTests.java | 923 ++++++++++++++++++ .../resource/TemporaryDirectoryDirTests.java | 56 ++ .../resource/TemporaryDirectoryTests.java | 552 +++++++++++ .../testkit/ExecutionResults.java | 14 +- .../junitpioneer/testkit/PioneerTestKit.java | 16 +- .../testkit/PioneerTestKitTests.java | 18 + .../testkit/assertion/PioneerAssert.java | 324 +----- .../PioneerExecutionResultAssert.java | 332 +++++++ .../testkit/assertion/PioneerPathAssert.java | 64 ++ src/test/module/module-info.java | 2 + 32 files changed, 4209 insertions(+), 361 deletions(-) create mode 100644 docs/resources.adoc create mode 100644 docs/temp-directory-removed.adoc create mode 100644 src/demo/java/org/junitpioneer/jupiter/resource/InMemoryDirectory.java create mode 100644 src/demo/java/org/junitpioneer/jupiter/resource/ResourceExtensionDemo.java create mode 100644 src/demo/java/org/junitpioneer/jupiter/resource/package-info.java create mode 100644 src/main/java/org/junitpioneer/jupiter/resource/Dir.java create mode 100644 src/main/java/org/junitpioneer/jupiter/resource/New.java create mode 100644 src/main/java/org/junitpioneer/jupiter/resource/PathDeleter.java create mode 100644 src/main/java/org/junitpioneer/jupiter/resource/Resource.java create mode 100644 src/main/java/org/junitpioneer/jupiter/resource/ResourceExtension.java create mode 100644 src/main/java/org/junitpioneer/jupiter/resource/ResourceFactory.java create mode 100644 src/main/java/org/junitpioneer/jupiter/resource/Shared.java create mode 100644 src/main/java/org/junitpioneer/jupiter/resource/TemporaryDirectory.java create mode 100644 src/main/java/org/junitpioneer/jupiter/resource/package-info.java create mode 100644 src/test/java/org/junitpioneer/jupiter/resource/PathDeleterTests.java create mode 100644 src/test/java/org/junitpioneer/jupiter/resource/ResourcesParallelismTests.java create mode 100644 src/test/java/org/junitpioneer/jupiter/resource/ResourcesTests.java create mode 100644 src/test/java/org/junitpioneer/jupiter/resource/TemporaryDirectoryDirTests.java create mode 100644 src/test/java/org/junitpioneer/jupiter/resource/TemporaryDirectoryTests.java create mode 100644 src/test/java/org/junitpioneer/testkit/assertion/PioneerExecutionResultAssert.java create mode 100644 src/test/java/org/junitpioneer/testkit/assertion/PioneerPathAssert.java diff --git a/.gitignore b/.gitignore index 447f51529..d881dc1d7 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ hs_err_pid* # MacOS .DS_Store + +# asdf-vm +.tool-versions diff --git a/build.gradle.kts b/build.gradle.kts index c022207b6..b636ff4f4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,7 @@ plugins { id("org.shipkit.shipkit-github-release") version "1.1.15" id("com.github.ben-manes.versions") version "0.42.0" id("io.github.gradle-nexus.publish-plugin") version "1.1.0" + id("org.gradlex.extra-java-module-info") version "1.0" } plugins.withType().configureEach { @@ -191,6 +192,15 @@ nexusPublishing { } } +extraJavaModuleInfo { + failOnMissingModuleInfo.set(false) + automaticModule("com.google.guava:failureaccess", "com.google.guava.failureaccess") + automaticModule("com.google.guava:listenablefuture", "com.google.guava.listenablefuture") + automaticModule("com.google.code.findbugs:jsr305", "com.google.code.findbugs.jsr305") + automaticModule("com.google.j2objc:j2objc-annotations", "com.google.j2objc.annotations") + automaticModule("com.google.jimfs:jimfs", "com.google.jimfs") +} + tasks { sourceSets { @@ -288,6 +298,7 @@ tasks { val demoTests by registering(JvmTestSuite::class) { dependencies { implementation(project) + implementation("com.google.jimfs:jimfs:1.2") implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") implementation("org.assertj:assertj-core:3.22.0") } diff --git a/docs/docs-nav.yml b/docs/docs-nav.yml index 3a1e9e68b..2935b216f 100644 --- a/docs/docs-nav.yml +++ b/docs/docs-nav.yml @@ -26,14 +26,18 @@ url: /docs/report-entries/ - title: "Range Sources" url: /docs/range-sources/ + - title: "Resources" + url: /docs/resources/ - title: "Retrying Failing Tests" url: /docs/retrying-test/ - title: "Standard Input and Output" url: /docs/standard-input-output/ - title: "Measuring time with a Stopwatch" url: /docs/stopwatch/ - - title: "Temporary Files and Directories (removed in 1.0)" + - title: "Temporary Directories" url: /docs/temp-directory/ + - title: "Temporary Files and Directories (removed in 1.0)" + url: /docs/temp-directory-removed/ - title: "Vintage @Test" url: /docs/vintage-test/ diff --git a/docs/resources.adoc b/docs/resources.adoc new file mode 100644 index 000000000..49b7b697e --- /dev/null +++ b/docs/resources.adoc @@ -0,0 +1,364 @@ +:page-title: Creating and Sharing Resourced +:page-description: Extends JUnit Jupiter with a mechanism to create, share, and inject resources like temporary directories or a port. +:xp-demo-dir: ../src/demo/java +:demo: {xp-demo-dir}/org/junitpioneer/jupiter/resource/ResourceExtensionDemo.java +:in-memory-directory: {xp-demo-dir}/org/junitpioneer/jupiter/resource/InMemoryDirectory.java + +Some tests need "resources", which need to be cleaned up when finished, and sometimes, many tests need to access these resources. +Furthermore, you may be running your JUnit Jupiter tests in parallel, which makes sharing these resources flaky. + +For example, you might want to share a temporary directory. +This can be a problem if the directory isn't deleted after your tests, or your tests try to read and write files at the same time. + +This extension separates parsing annotations, injecting new or shared resources, and registering them for getting cleaned up (which is needed for all kinds of resources) from actually creating and closing them (which is specific to each kind of resource). + +[NOTE] +==== +This article describes the general mechanisms shared by different resource extensions but not their specifics. +Check the individual documentations for that: + +* link:docs/temp-directory[Temporary directory] +==== + +The first part of this article describes how to use a resource with this extension. +The second part describes how to integrate your own kind of resource with this mechanism. +In both cases, the temporary directory extension will be used as an example, but what's described here applies to other resources as well. + +== Using Resources + +There are two different approaches to using a resource: + +* Creating a new one for a given test. +* Sharing one between several tests. + +=== Creating a New Resource + +To create a new resource for a given test: + +[source,java,indent=0] +---- +include::{demo}[tag=create_new_resources_demo] +---- + +(`TemporaryDirectory` is a built-in resource for creating link:docs/temp-directory[temporary directories].) + +This will create a brand-new resource for each test, and each resource will be "closed" at the end of its associated test. + +So in this case, a new temporary directory will be created for `test1` and another one will be created for `test2`. +The temporary directory for `test1` will be deleted when the test is finished. +Likewise, the temporary directory for `test2` will also be deleted right after its test. + +=== Creating a New Resource with Arguments + +Some resources accept string arguments to control their behaviour. + +For example, `TemporaryDirectory` may accept a String argument to set the prefix of the name of the temporary directory that is created: + +[source,java,indent=0] +---- +include::{demo}[tag=create_new_resource_with_arg_demo] +---- + +[#sharing_a_resource] +=== Sharing a Resource + +To create a resource that is shared by multiple tests: + +[source,java,indent=0] +---- +include::{demo}[tag=create_shared_resource_demo] +---- + +(`TemporaryDirectory` is a built-in resource for creating link:docs/temp-directory[temporary directories].) + +This will create a single resource instance that will be injected into all the tests. +It will be "closed" when all the tests are finished. +(See <> for a caveat on this.) + +So in this case, a single temporary directory will be shared across all tests, and it will only be deleted when all the tests have run. + +[NOTE] +==== +When sharing resources, you may want to force your tests to run in a certain order, so that e.g. files written to a temporary directory in one test can be read from another test. + +Use JUnit Jupiter's https://junit.org/junit5/docs/current/user-guide/#writing-tests-test-execution-order[Test Execution Order] feature to do this. +==== + +=== Sharing Multiple Resources + +The following code snippet shows an example of creating two shared resources. + +In this case, tests `firstSharedResource1` and `firstSharedResource2` use the same temporary directory, and test `secondSharedResource` uses a different temporary directory. + +[source,java,indent=0] +---- +include::{demo}[tag=create_multiple_shared_resources_demo] +---- + +[#scope_of_a_shared_resource] +=== Scope of a Shared Resource + +By default, a shared resource will be closed when the test file using it has finished. +This means that if a resource is shared across two or more test files, it will be closed and re-created for _each_ test file. + +If this behaviour isn't what you want, you can change the "scope" of the resource to "global", which will keep the resource around until _all_ test files have finished: + +[source,java,indent=0] +---- +include::{demo}[tag=create_global_shared_resource_demo_first] +---- + +[source,java,indent=0] +---- +include::{demo}[tag=create_global_shared_resource_demo_second] +---- + +[NOTE] +==== + +You are not limited to singletons. +You can create as many global shared resources as you want, as long as they have different names. + +==== + +=== Sharing Resources with Arguments + +[NOTE] +==== +We do not support creating shared resources with arguments. + +This is because if a test refers to a shared resource with the name "Foo" without arguments, then later another test refers to it with one argument, there is no reasonable way to fulfill that request. + +Furthermore, even if this was supported, the behavior would change if the first and second tests ever ran in opposite order, which is very likely when tests are configured to run in parallel. +==== + +== Integrating Resources + +This extension allows you to integrate your own kind of resource with the mechanisms described above. + +To do that, you need to implement `Resource` and `ResourceFactory`, where `T` is the type of resource you want to provide (e.g. `Path` for temporary directories). +Then you can reference the factory type in the `@New` and `@Shared` annotations. + +=== Creating Factories + +This extension will create a single `ResourceFactory`, which hence needs a parameterless constructor. +If there's no such constructor, the extension will throw an exception. + +=== Creating Resources + +The factory's `create` method gets called when: + +* a test with a `@New`-annotated parameter is about to run +* a test with a `@Shared`-annotated parameter is about to run and no shared resource with the configured name could be found in the configured scope + +The extension will then populate the parameter with the contents of the returned `Resource`. + +`@New` parameters may have associated string `arguments`. +These will be passed to the factory's `ResourceFactory::create` method as a `List` when a resource needs to be created. +You have full control over what these arguments mean and how they are used and should document these details in your resource factory's JavaDoc. + +=== Closing Resources and Factories + +You can opt-in to cleaning resources up by implementing `close` methods on both of these types. + +* For a `@New` parameter, `Resource::close` will be called and the resource will be made eligible for garbage collection immediately after the test. +* For a `@Shared` parameter, `Resource::close` will be called when all tests are finished (regardless of the resource's scope). + The resource will be cached in-memory until then. +* `ResourceFactory::close` will be called when all tests are finished. + +Overriding these `close()` methods is optional - they will do nothing by default. + +=== Examples + +These examples show how to create a resource called `InMemoryDirectory` for an in-memory filesystem using https://github.com/google/jimfs[Jimfs]. + +==== Set up the Factory + +This example shows how to create the `ResourceFactory` for your resources. + +[source,java,indent=0] +---- +public final class InMemoryDirectory implements ResourceFactory { + + private static final AtomicInteger DIRECTORY_NAME = new AtomicInteger(); + + // The resource factory we want to create resources with. + // In this case, an in-memory filesystem. + private final FileSystem fileSystem; + + // NOTE: The constructor must be parameter-less. + public InMemoryDirectory() { + this.fileSystem = Jimfs.newFileSystem(Configuration.unix()); + } + + @Override + public Resource create(List arguments) throws Exception { + // ... + } + +} +---- + +==== Close the Factory + +This example shows how to delete, tear down or otherwise "close" everything associated with the factory when this extension is ready to call the factory's `ResourceFactory.close()` method. + +[source,java,indent=0] +---- +public final class InMemoryDirectory implements ResourceFactory { + + // ... + + private final FileSystem fileSystem; + + // ... + + @Override + public void close() throws Exception { + this.fileSystem.close(); + } + +} +---- + +==== Create a Resource + +This example shows how to create an actual `Resource` from your factory, with the `ResourceFactory.create()` method. + +[source,java,indent=0] +---- +public final class InMemoryDirectory implements ResourceFactory { + + // ... + + private final FileSystem fileSystem; + + // ... + + @Override + public Resource create(List arguments) throws Exception { + // Create a new resource from the factory. + // In this case, return a new directory from + // the in-memory filesystem. + + Path newInMemoryDirectory = this.fileSystem.getPath("/" + DIRECTORY_NAME.getAndIncrement()); + Files.createDirectory(newInMemoryDirectory); + + return new Resource() { + + @Override + public Path get() throws Exception { + return newInMemoryDirectory; + } + + // ... + + }; + } + +} +---- + +==== Close a Resource + +This example shows how to expand the previous resource to delete, tear down or otherwise "close" everything associated with it when this extension is ready to call the resource's `Resource.close()` method. + +[source,java,indent=0] +---- +public final class InMemoryDirectory implements ResourceFactory { + + // ... + + private final FileSystem fileSystem; + + // ... + + @Override + public Resource create(List arguments) throws Exception { + + Path newInMemoryDirectory = // ... + // ... + + return new Resource() { + + // ... + + @Override + public void close() throws Exception { + Files.walkFileTree(newInMemoryDirectory, new SimpleFileVisitor() { + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + + }); + } + + }; + } + +} +---- + +==== Working with Arguments from `@New` + +This example shows how to interpret the first argument to be the _prefix_ of the name of the to-be-created in-memory directory resource. + +[source,java,indent=0] +---- +public final class InMemoryDirectory implements ResourceFactory { + + // ... + + @Override + public Resource create(List arguments) throws Exception { + String directoryPrefix = (arguments.size() == 1) ? arguments.get(0) : ""; + Path newInMemoryDirectory = this.fileSystem.getPath("/" + directoryPrefix + DIRECTORY_NAME.getAndIncrement()); + Files.createDirectory(newInMemoryDirectory); + + return new Resource() { + + @Override + public Path get() throws Exception { + return newInMemoryDirectory; + } + + // ... + + }; + + } +} +---- + +==== Putting It All Together + +This example shows everything from the previous few sections in one big code snippet. + +[source,java,indent=0] +---- +include::{in-memory-directory}[tag=in_memory_directory] +---- + +== Thread-Safety + +This extension is safe to use during parallel test execution. + +Tests, test constructors, and lifecycle methods with `@New` resources will run in parallel. + +Tests, test constructors, and lifecycle methods with `@Shared` resources will be forced to run *sequentially*, even if parallel execution has been enabled. +This is because resources may be _mutable_, and if the tests were allowed to run in parallel, they could mutate the resources in a non-deterministic way. +Temporary directories are a good example of this, as tests can create new subdirectories and files inside them. + +[CAUTION] +==== +Be careful not to save resources in fields from any test method, including `@BeforeAll` and `@BeforeEach` methods, as this extension cannot guarantee that such resources are read or mutated sequentially. +==== diff --git a/docs/temp-directory-removed.adoc b/docs/temp-directory-removed.adoc new file mode 100644 index 000000000..c383162c8 --- /dev/null +++ b/docs/temp-directory-removed.adoc @@ -0,0 +1,61 @@ +:page-title: Temporary Files and Directories (removed in 1.0) +:page-description: Extends JUnit Jupiter with `@TempDir`, which create and clean up a temporary directory. + +NOTE: JUnit Jupiter 5.4 introduced a https://junit.org/junit5/docs/current/user-guide/#writing-tests-built-in-extensions-TempDirectory[built-in `@TempDir` extension]. This extension was built before then, but its only advantage over the JUnit Jupiter extension is support for custom file systems. We felt this didn't have enough value, so we removed this extension in JUnit Pioneer 1.0. + +NOTE: We've tackled this problem again, and *introduced a link:docs/resource-temporary-directory[resource-based extension]*. It allows temporary directories to be created that last longer than a single test method. Please give it a try! + +The `TempDirectory` extension can be used to create and clean up a temporary directory for an individual test or all tests in a test class. +To use it, simply register the extension and add a parameter of type `java.nio.file.Path` to your test or lifecycle method or constructor. + +For example, the following test registers the extension for a single test method, creates and writes a file to the temporary directory and checks its content. + +[source,java] +---- +@Test +@ExtendWith(TempDirectory.class) +void test(@TempDir Path tempDir) { + Path file = tempDir.resolve("test.txt"); + writeFile(file); + assertExpectedFileContent(file); +} +---- + +In addition to the default file system, the extension can be used with any `FileSystem` implementation, e.g. https://github.com/google/jimfs[Jimfs]. +In order to use a custom file system, simply register the extension programmatically and pass a provider of a custom parent directory of type `Path`. +The following example uses the Jimfs `FileSystem` and passes a custom `tmp` parent directory to the static factory method `TempDirectory::createInCustomDirectory`. + +[source,java] +---- +class MyTests { + + private static FileSystem fileSystem; + + @BeforeAll + static void createFileSystem() { + fileSystem = Jimfs.newFileSystem(); + } + + @AfterAll + static void closeFileSystem() throws Exception { + fileSystem.close(); + } + + @RegisterExtension + Extension tempDirectory = TempDirectory.createInCustomDirectory(() -> + Files.createDirectories(fileSystem.getPath("tmp"))); + + @Test + void test(@TempDir Path tempDir) { + Path file = tempDir.resolve("test.txt"); + writeFile(file); + assertExpectedFileContent(file); + } + +} +---- + +== Thread-Safety + +This extension's thread-safety is undetermined, we recommend to be cautious when using it during https://junit.org/junit5/docs/current/user-guide/#writing-tests-parallel-execution[parallel test execution]. +(We're working on it.) diff --git a/docs/temp-directory.adoc b/docs/temp-directory.adoc index 45916641b..86a65fdf7 100644 --- a/docs/temp-directory.adoc +++ b/docs/temp-directory.adoc @@ -1,60 +1,72 @@ -:page-title: Temporary Files and Directories (removed in 1.0) -:page-description: Extends JUnit Jupiter with `@TempDir`, which create and clean up a temporary directory. +:page-title: Temporary Directory +:page-description: Extends JUnit Jupiter with a mechanism to create, share, and inject temporary directories. +:xp-demo-dir: ../src/demo/java +:demo: {xp-demo-dir}/org/junitpioneer/jupiter/resource/ResourceExtensionDemo.java +:resources-doc: ./resources.adoc -NOTE: Since JUnit Jupiter 5.4, there's a https://junit.org/junit5/docs/current/user-guide/#writing-tests-built-in-extensions-TempDirectory[built-in `@TempDir` extension]. -It doesn't support custom file systems, but other than that it's much better than this version, so *we removed the extension in JUnit Pioneer 1.0*. -We plan to tackle this problem again in the future - see https://github.com/junit-pioneer/junit-pioneer/issues/348[issue #348]. +[NOTE] +==== +This article describes the specifics of how to inject temporary directories into your tests. +Most of the extension's features are shared with other resources, though, and are described in link:docs/resources[the article covering resources in general] (e.g. thread-safety and sharing resources), so make sure to read that one as well. +==== -The `TempDirectory` extension can be used to create and clean up a temporary directory for an individual test or all tests in a test class. -To use it, simply register the extension and add a parameter of type `java.nio.file.Path` to your test or lifecycle method or constructor. +A temporary directory is a directory on the machine's on-disk filesystem that is created for one or more tests and deleted when it is no longer needed. -For example, the following test registers the extension for a single test method, creates and writes a file to the temporary directory and checks its content. +Unlike JUnit Jupiter's `@TempDir` annotation, temporary directories created with this extension can be configured to be deleted when the current test class is finished, or when all tests have finished. -[source,java] +Also, this extension can configure the _prefix_ of a `@New` temporary directory's name, which might be useful for test reporting or debugging reasons. + +== Creating a New Temporary Directory + +To create a new temporary directory for a given test: + +[source,java,indent=0] ---- -@Test -@ExtendWith(TempDirectory.class) -void test(@TempDir Path tempDir) { - Path file = tempDir.resolve("test.txt"); - writeFile(file); - assertExpectedFileContent(file); -} +include::{demo}[tag=create_new_dir_demo] ---- -In addition to the default file system, the extension can be used with any `FileSystem` implementation, e.g. https://github.com/google/jimfs[Jimfs]. -In order to use a custom file system, simply register the extension programmatically and pass a provider of a custom parent directory of type `Path`. -The following example uses the Jimfs `FileSystem` and passes a custom `tmp` parent directory to the static factory method `TempDirectory::createInCustomDirectory`. +`@Dir` is the shorthand annotation for `@New`, meaning it achieves the same result more succinctly than the long form: -[source,java] +[source,java,indent=0] +---- +include::{demo}[tag=create_new_resources_demo] ---- -class MyTests { - private static FileSystem fileSystem; +== Creating with Arguments - @BeforeAll - static void createFileSystem() { - fileSystem = Jimfs.newFileSystem(); - } +To specify the _prefix_ of a new temporary directory's name, pass the prefix as an argument to the `@New` annotation: - @AfterAll - static void closeFileSystem() throws Exception { - fileSystem.close(); - } +[source,java,indent=0] +---- +include::{demo}[tag=create_new_resource_with_arg_demo] +---- - @RegisterExtension - Extension tempDirectory = TempDirectory.createInCustomDirectory(() -> - Files.createDirectories(fileSystem.getPath("tmp"))); +[NOTE] +==== +The `@Dir` annotation does not yet support arguments. +Follow https://github.com/junit-pioneer/junit-pioneer/issues/648[this issue] for updates on this feature. +==== - @Test - void test(@TempDir Path tempDir) { - Path file = tempDir.resolve("test.txt"); - writeFile(file); - assertExpectedFileContent(file); - } +== Sharing a Temporary Directory -} +To create a temporary directory that is shared by multiple tests: + +[source,java,indent=0] +---- +include::{demo}[tag=create_shared_resource_demo] ---- +See the link:resources.adoc#sharing_a_resource[ Resources page] for more information. + +[NOTE] +==== +There's no shorthand form of `@Shared`. +Follow https://github.com/junit-pioneer/junit-pioneer/issues/648[this issue] for updates on this feature. +==== + == Thread-Safety -This extension's thread-safety is undetermined, we recommend to be cautious when using it during https://junit.org/junit5/docs/current/user-guide/#writing-tests-parallel-execution[parallel test execution]. +This extension is safe to use during parallel test execution. + +Tests with `@New` temporary directories will continue to run in parallel. +Tests with `@Shared` temporary directories will be forced to run *sequentially*, even if parallel execution has been enabled. diff --git a/src/demo/java/org/junitpioneer/jupiter/resource/InMemoryDirectory.java b/src/demo/java/org/junitpioneer/jupiter/resource/InMemoryDirectory.java new file mode 100644 index 000000000..507f27831 --- /dev/null +++ b/src/demo/java/org/junitpioneer/jupiter/resource/InMemoryDirectory.java @@ -0,0 +1,81 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; + +// tag::in_memory_directory[] + +public final class InMemoryDirectory implements ResourceFactory { + + private static final AtomicInteger DIRECTORY_NAME = new AtomicInteger(); + + private final FileSystem fileSystem; + + public InMemoryDirectory() { + this.fileSystem = Jimfs.newFileSystem(Configuration.unix()); + } + + @Override + public Resource create(List arguments) throws Exception { + String directoryPrefix = (arguments.size() == 1) ? arguments.get(0) : ""; + + Path newInMemoryDirectory = this.fileSystem.getPath("/" + directoryPrefix + DIRECTORY_NAME.getAndIncrement()); + Files.createDirectory(newInMemoryDirectory); + + return new Resource() { + + @Override + public Path get() throws Exception { + return newInMemoryDirectory; + } + + @Override + public void close() throws Exception { + Files.walkFileTree(newInMemoryDirectory, new SimpleFileVisitor() { + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + + }); + } + + }; + } + + @Override + public void close() throws Exception { + this.fileSystem.close(); + } + +} + +// end::in_memory_directory[] diff --git a/src/demo/java/org/junitpioneer/jupiter/resource/ResourceExtensionDemo.java b/src/demo/java/org/junitpioneer/jupiter/resource/ResourceExtensionDemo.java new file mode 100644 index 000000000..eec801407 --- /dev/null +++ b/src/demo/java/org/junitpioneer/jupiter/resource/ResourceExtensionDemo.java @@ -0,0 +1,130 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class ResourceExtensionDemo { + + // tag::create_new_resources_demo[] + void test1(@New(TemporaryDirectory.class) Path tempDir) { + // Test code goes here, e.g., + assertTrue(Files.exists(tempDir)); + } + + void test2(@New(TemporaryDirectory.class) Path tempDir) { + // This temporary directory is different to the first one. + } + // end::create_new_resources_demo[] + + // tag::create_new_dir_demo[] + void dirTest1(@Dir Path tempDir) { + // Test code goes here, e.g., + assertTrue(Files.exists(tempDir)); + } + + void dirTest2(@Dir Path tempDir) { + // This temporary directory is different to the first one. + } + // end::create_new_dir_demo[] + + // @formatter:off + // tag::create_new_resource_with_arg_demo[] + void testWithArg( + @New(value = TemporaryDirectory.class, arguments = "customPrefix") + Path tempDir) { + // Test code goes here, e.g., + Path rootTempDir = Paths.get(System.getProperty("java.io.tmpdir")); + assertTrue(rootTempDir.relativize(tempDir).startsWith("customPrefix")); + } + // end::create_new_resource_with_arg_demo[] + // @formatter:on + + // @formatter:off + // tag::create_shared_resource_demo[] + void sharedResourceTest1( + @Shared(factory = TemporaryDirectory.class, name = "sharedTempDir") + Path sharedTempDir) { + // Test code goes here, e.g., + assertTrue(Files.exists(sharedTempDir)); + } + + void sharedResourceTest2( + @Shared(factory = TemporaryDirectory.class, name = "sharedTempDir") + Path sharedTempDir) { + // "sharedTempDir" is shared with the temporary directory of + // the same name in test "sharedResourceTest1", so any created + // subdirectories and files will be shared. + } + // end::create_shared_resource_demo[] + // @formatter:on + + // @formatter:off + // tag::create_multiple_shared_resources_demo[] + void firstSharedResource1( + @Shared(factory = TemporaryDirectory.class, name = "first") + Path first) { + // Test code working with first shared resource... + } + + void firstSharedResource2( + @Shared(factory = TemporaryDirectory.class, name = "first") + Path first) { + // Test code working with first shared resource... + } + + void secondSharedResource( + @Shared(factory = TemporaryDirectory.class, name = "second") + Path second) { + // This shared resource is different! + } + // end::create_multiple_shared_resources_demo[] + // @formatter:on + +} + +// @formatter:off +// tag::create_global_shared_resource_demo_first[] +class FirstTest { + + void test( + @Shared( + factory = TemporaryDirectory.class, + name = "globalTempDir", + scope = Shared.Scope.GLOBAL) + Path tempDir) { + // Test code using the global shared resource... + } + +} +// end::create_global_shared_resource_demo_first[] +// @formatter:on + +// @formatter:off +// tag::create_global_shared_resource_demo_second[] +class SecondTest { + + void test( + @Shared( + factory = TemporaryDirectory.class, + name = "globalTempDir", + scope = Shared.Scope.GLOBAL) + Path tempDir) { + // Test code using the global shared resource... + } + +} +// end::create_global_shared_resource_demo_second[] +// @formatter:on diff --git a/src/demo/java/org/junitpioneer/jupiter/resource/package-info.java b/src/demo/java/org/junitpioneer/jupiter/resource/package-info.java new file mode 100644 index 000000000..fbe55a3d5 --- /dev/null +++ b/src/demo/java/org/junitpioneer/jupiter/resource/package-info.java @@ -0,0 +1,5 @@ +/** + * Package containing demonstration tests for corresponding package in the main project. + */ + +package org.junitpioneer.jupiter.resource; diff --git a/src/main/java/org/junitpioneer/jupiter/package-info.java b/src/main/java/org/junitpioneer/jupiter/package-info.java index b9571e46e..e5d555e37 100644 --- a/src/main/java/org/junitpioneer/jupiter/package-info.java +++ b/src/main/java/org/junitpioneer/jupiter/package-info.java @@ -21,6 +21,7 @@ *
  • {@link org.junitpioneer.jupiter.issue issue}
  • *
  • {@link org.junitpioneer.jupiter.json json}
  • *
  • {@link org.junitpioneer.jupiter.params params}
  • + *
  • {@link org.junitpioneer.jupiter.resource resource}
  • * * */ diff --git a/src/main/java/org/junitpioneer/jupiter/resource/Dir.java b/src/main/java/org/junitpioneer/jupiter/resource/Dir.java new file mode 100644 index 000000000..867bf63b8 --- /dev/null +++ b/src/main/java/org/junitpioneer/jupiter/resource/Dir.java @@ -0,0 +1,38 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * {@code @Dir} is a shorthand for {@code @New(TemporaryDirectory.class)}. + * + *

    It is part of the "resources" JUnit Jupiter extension, which pertains to anything that needs + * to be injected into tests and which may need to be started up or torn down. Temporary + * directories are a common example. + * + *

    For more details and examples, see + * the documentation on temporary directories.

    + * + * @since 1.9.0 + * @see New + * @see TemporaryDirectory + * @see Resource + * @see ResourceFactory + */ +@New(TemporaryDirectory.class) +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Dir { +} diff --git a/src/main/java/org/junitpioneer/jupiter/resource/New.java b/src/main/java/org/junitpioneer/jupiter/resource/New.java new file mode 100644 index 000000000..7c2ed0d3d --- /dev/null +++ b/src/main/java/org/junitpioneer/jupiter/resource/New.java @@ -0,0 +1,55 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * {@code @New} is used to create a new resource. + * + *

    It is part of the "resources" JUnit Jupiter extension, which pertains to anything that needs + * to be injected into tests and which may need to be started up or torn down. Temporary + * directories are a common example. + * + *

    This class is intended for users.

    + * + *

    For more details and examples, see + * the documentation on resources + * and temporary directories.

    + * + * @since 1.9.0 + * @see Resource + * @see ResourceFactory + */ +@ExtendWith(ResourceExtension.class) +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) +public @interface New { + + /** + * The class of the resource factory to get the resource from. + */ + Class> value(); + + /** + * An array of string arguments to pass to the resource factory. + * + *

    Refer to the documentation of the resource factory implementation that is passed to this + * annotation for more information on which arguments are accepted and what they do.

    + */ + String[] arguments() default {}; + +} diff --git a/src/main/java/org/junitpioneer/jupiter/resource/PathDeleter.java b/src/main/java/org/junitpioneer/jupiter/resource/PathDeleter.java new file mode 100644 index 000000000..916beb75b --- /dev/null +++ b/src/main/java/org/junitpioneer/jupiter/resource/PathDeleter.java @@ -0,0 +1,40 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; + +class PathDeleter extends SimpleFileVisitor { + + static final PathDeleter INSTANCE = new PathDeleter(); + + private PathDeleter() { + // private constructor to prevent instantiation of utility class + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + +} diff --git a/src/main/java/org/junitpioneer/jupiter/resource/Resource.java b/src/main/java/org/junitpioneer/jupiter/resource/Resource.java new file mode 100644 index 000000000..31a91f87c --- /dev/null +++ b/src/main/java/org/junitpioneer/jupiter/resource/Resource.java @@ -0,0 +1,51 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import org.junit.jupiter.api.extension.ExtensionContext; + +/** + * {@code Resource} is the common interface for "resources", as managed by {@link ResourceFactory} + * implementations. + * + *

    It is part of the "resources" JUnit Jupiter extension, which pertains to anything that needs + * to be injected into tests and which may need to be started up or torn down. Temporary + * directories are a common example. + * + *

    This class is intended for implementors of new kinds of resources.

    + * + *

    For more details and examples, see + * the documentation on resources.

    + * + * @param the type of the resource + * @since 1.9.0 + * @see ResourceFactory + */ +public interface Resource extends ExtensionContext.Store.CloseableResource { + + /** + * Returns the contents of the resource. + * + * @throws Exception if getting the resource failed + */ + T get() throws Exception; + + /** + * Closes the resource. + * + * @throws Exception if closing the resource failed + */ + @Override + default void close() throws Exception { + // no op by default + } + +} diff --git a/src/main/java/org/junitpioneer/jupiter/resource/ResourceExtension.java b/src/main/java/org/junitpioneer/jupiter/resource/ResourceExtension.java new file mode 100644 index 000000000..f82fe1e3b --- /dev/null +++ b/src/main/java/org/junitpioneer/jupiter/resource/ResourceExtension.java @@ -0,0 +1,483 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import static java.util.Arrays.asList; +import static java.util.Collections.unmodifiableList; +import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.toList; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.extension.DynamicTestInvocationContext; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.junit.platform.commons.support.AnnotationSupport; +import org.junit.platform.commons.support.ReflectionSupport; + +class ResourceExtension implements ParameterResolver, InvocationInterceptor { + + private static final ExtensionContext.Namespace NAMESPACE = // + ExtensionContext.Namespace.create(ResourceExtension.class); + + private static final Lock SHARED_ANNOTATION_RESOLUTION_LOCK = new ReentrantLock(); + + private static final AtomicLong KEY_GENERATOR = new AtomicLong(0); + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + if (parameterContext.isAnnotated(New.class) && parameterContext.isAnnotated(Shared.class)) { + // @formatter:off + String message = + String.format( + "Parameter [%s] in %s is annotated with both @New and @Shared", + parameterContext.getParameter(), testMethodDescription(extensionContext)); + // @formatter:on + throw new ParameterResolutionException(message); + } + return parameterContext.isAnnotated(New.class) || parameterContext.isAnnotated(Shared.class); + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + Optional newAnnotation = parameterContext.findAnnotation(New.class); + if (newAnnotation.isPresent()) { + ExtensionContext.Store testStore = extensionContext.getStore(NAMESPACE); + Object resource = resolveNew(newAnnotation.get(), testStore); + return checkType(resource, parameterContext.getParameter().getType()); + } + + Optional sharedAnnotation = parameterContext.findAnnotation(Shared.class); + if (sharedAnnotation.isPresent()) { + Parameter[] parameters = parameterContext.getDeclaringExecutable().getParameters(); + ExtensionContext.Store scopedStore = scopedStore(extensionContext, sharedAnnotation.get().scope()); + ExtensionContext.Store rootStore = extensionContext.getRoot().getStore(NAMESPACE); + Object resource = resolveShared(sharedAnnotation.get(), parameters, scopedStore, rootStore); + return checkType(resource, parameterContext.getParameter().getType()); + } + + // @formatter:off + String message = String.format( + "Parameter [%s] in %s is not annotated with @New or @Shared", + parameterContext.getParameter(), testMethodDescription(extensionContext)); + // @formatter:on + throw new ParameterResolutionException(message); + } + + private T checkType(Object resource, Class type) { + if (!type.isInstance(resource)) { + String message = String.format("Parameter [%s] is not of the correct target type %s", resource, type); + throw new ParameterResolutionException(message); + } + return type.cast(resource); + } + + private Object resolveNew(New newAnnotation, ExtensionContext.Store store) { + ResourceFactory resourceFactory = ReflectionSupport.newInstance(newAnnotation.value()); + store.put(uniqueKey(), resourceFactory); + + Resource resource = newResource(newAnnotation, resourceFactory); + store.put(uniqueKey(), resource); + + Object result; + try { + result = resource.get(); + } + catch (Exception ex) { + // @formatter:off + String message = String.format( + "Unable to get the contents of the resource created by `%s`", + resourceFactory.getClass().getTypeName()); + // @formatter:on + throw new ParameterResolutionException(message, ex); + } + + if (result == null) { + // @formatter:off + String message = String.format( + "The resource returned by [%s] was null, which is not allowed", + getMethod(resource.getClass(), "get")); + // @formatter:on + throw new ParameterResolutionException(message); + } + + return result; + } + + private Object resolveShared(Shared sharedAnnotation, Parameter[] parameters, ExtensionContext.Store scopedStore, + ExtensionContext.Store rootStore) { + // run sequentially, so that resources with the same name are never created twice at the same time + SHARED_ANNOTATION_RESOLUTION_LOCK.lock(); + try { + throwIfHasAnnotationWithSameNameButDifferentType(scopedStore, sharedAnnotation); + throwIfHasAnnotationWithSameNameButDifferentScope(rootStore, sharedAnnotation); + throwIfMultipleParametersHaveExactAnnotation(parameters, sharedAnnotation); + + ResourceFactory resourceFactory = scopedStore + .getOrComputeIfAbsent( // + factoryKey(sharedAnnotation), // + __ -> ReflectionSupport.newInstance(sharedAnnotation.factory()), // + ResourceFactory.class); + Resource resource = scopedStore + .getOrComputeIfAbsent( // + resourceKey(sharedAnnotation), // + __ -> newResource(sharedAnnotation, resourceFactory), // + Resource.class); + putNewLockForShared(sharedAnnotation, scopedStore); + + Object result; + try { + result = resource.get(); + } + catch (Exception ex) { + // @formatter:off + String message = String.format( + "Unable to get the contents of the resource created by `%s`", + sharedAnnotation.factory()); + // @formatter:on + throw new ParameterResolutionException(message, ex); + } + + if (result == null) { + // @formatter:off + String message = String.format( + "The resource returned by [%s] was null, which is not allowed", + getMethod(resource.getClass(), "get")); + // @formatter:on + throw new ParameterResolutionException(message); + } + + return result; + } + finally { + SHARED_ANNOTATION_RESOLUTION_LOCK.unlock(); + } + } + + private Resource newResource(Object newOrSharedAnnotation, ResourceFactory resourceFactory) { + List arguments; + if (newOrSharedAnnotation instanceof New) { + arguments = unmodifiableList(asList(((New) newOrSharedAnnotation).arguments())); + } else { + arguments = Collections.emptyList(); + } + + Resource result; + try { + result = resourceFactory.create(arguments); + } + catch (Exception ex) { + String message = // + String.format("Unable to create a resource from `%s`", resourceFactory.getClass().getTypeName()); + throw new ParameterResolutionException(message, ex); + } + + if (result == null) { + // @formatter:off + String message = String.format( + "The `Resource` instance returned by the factory method [%s] with arguments %s was null, which is not allowed", + getMethod(resourceFactory.getClass(), "create", List.class), + arguments); + // @formatter:on + throw new ParameterResolutionException(message); + } + + return result; + } + + private void throwIfHasAnnotationWithSameNameButDifferentType(ExtensionContext.Store scopedStore, + Shared sharedAnnotation) { + ResourceFactory presentResourceFactory = // + scopedStore.getOrDefault(factoryKey(sharedAnnotation), ResourceFactory.class, null); + + if (presentResourceFactory == null) { + scopedStore.put(keyOfFactoryKey(sharedAnnotation), factoryKey(sharedAnnotation)); + } else { + String presentResourceFactoryName = // + scopedStore.getOrDefault(keyOfFactoryKey(sharedAnnotation), String.class, null); + + if (factoryKey(sharedAnnotation).equals(presentResourceFactoryName) + && !sharedAnnotation.factory().equals(presentResourceFactory.getClass())) { + // @formatter:off + String message = + String.format( + "Two or more parameters are annotated with @Shared annotations with the name \"%s\" " + + "but with different factory classes", + sharedAnnotation.name()); + // @formatter:on + throw new ParameterResolutionException(message); + } + } + } + + private void throwIfHasAnnotationWithSameNameButDifferentScope(ExtensionContext.Store rootStore, + Shared sharedAnnotation) { + Shared presentSharedAnnotation = rootStore + .getOrDefault(sharedAnnotationKey(sharedAnnotation), Shared.class, null); + + if (presentSharedAnnotation == null) { + rootStore.put(sharedAnnotationKey(sharedAnnotation), sharedAnnotation); + } else { + if (presentSharedAnnotation.name().equals(sharedAnnotation.name()) + && !presentSharedAnnotation.scope().equals(sharedAnnotation.scope())) { + // @formatter:off + String message = + String.format( + "Two or more parameters are annotated with @Shared annotations with the name " + + "\"%s\" but with different scopes", + sharedAnnotation.name()); + // @formatter:on + throw new ParameterResolutionException(message); + } + } + } + + private void throwIfMultipleParametersHaveExactAnnotation(Parameter[] parameters, Shared sharedAnnotation) { + long parameterCount = // + Arrays.stream(parameters).filter(parameter -> hasAnnotation(parameter, sharedAnnotation)).count(); + if (parameterCount > 1) { + // @formatter:off + String message = + String.format( + "A test method has %d parameters annotated with @Shared with the same factory type " + + "and name; this is redundant, so it is not allowed", + parameterCount); + // @formatter:on + throw new ParameterResolutionException(message); + } + } + + private boolean hasAnnotation(Parameter parameter, Shared sharedAnnotation) { + return AnnotationSupport + .findAnnotation(parameter, Shared.class) + .filter(shared -> shared.factory().equals(sharedAnnotation.factory())) + .filter(shared -> shared.name().equals(sharedAnnotation.name())) + .isPresent(); + } + + private long uniqueKey() { + return KEY_GENERATOR.getAndIncrement(); + } + + private String factoryKey(Shared sharedAnnotation) { + return sharedAnnotation.name() + " resource factory"; + } + + private String resourceKey(Shared sharedAnnotation) { + return sharedAnnotation.name() + " resource"; + } + + private String resourceLockKey(Shared sharedAnnotation) { + return sharedAnnotation.name() + " resource lock"; + } + + private String keyOfFactoryKey(Shared sharedAnnotation) { + return sharedAnnotation.name() + " resource factory key"; + } + + private String sharedAnnotationKey(Shared sharedAnnotation) { + return sharedAnnotation.name() + " shared annotation"; + } + + private String testMethodDescription(ExtensionContext extensionContext) { + return extensionContext.getTestMethod().map(method -> "method [" + method + ']').orElse("an unknown method"); + } + + private Method getMethod(Class clazz, String method, Class... parameterTypes) { + try { + return clazz.getMethod(method, parameterTypes); + } + catch (NoSuchMethodException e) { + throw new IllegalStateException( + String.format("There should be a `%s` method on class `%s`", method, clazz.getTypeName()), e); + } + } + + @Override + public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + runSequentially(invocation, invocationContext.getExecutable(), extensionContext); + } + + @Override + public T interceptTestFactoryMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + return runSequentially(invocation, invocationContext.getExecutable(), extensionContext); + } + + @Override + public void interceptDynamicTest(Invocation invocation, DynamicTestInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + runSequentially(invocation, testFactoryMethod(extensionContext), extensionContext); + } + + @Override + public void interceptTestTemplateMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + runSequentially(invocation, invocationContext.getExecutable(), extensionContext); + } + + @Override + public T interceptTestClassConstructor(Invocation invocation, + ReflectiveInvocationContext> invocationContext, ExtensionContext extensionContext) + throws Throwable { + return runSequentially(invocation, invocationContext.getExecutable(), extensionContext); + } + + @Override + public void interceptBeforeAllMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + runSequentially(invocation, invocationContext.getExecutable(), extensionContext); + } + + @Override + public void interceptAfterAllMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + runSequentially(invocation, invocationContext.getExecutable(), extensionContext); + } + + @Override + public void interceptBeforeEachMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + runSequentially(invocation, invocationContext.getExecutable(), extensionContext); + } + + @Override + public void interceptAfterEachMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + runSequentially(invocation, invocationContext.getExecutable(), extensionContext); + } + + private T runSequentially(Invocation invocation, Executable executable, ExtensionContext extensionContext) + throws Throwable { + // Parallel tests must not concurrently access shared resources. To ensure that, we associate a lock with + // each shared resource and require a test to hold all locks associated with the shared resources it uses. + // + // This harbors a risk of deadlocks. For example, given these tests and the respective shared resources + // that they want to use: + // + // - test1 -> [A, B] + // - test2 -> [B, C] + // - test3 -> [C, A] + // + // If test1 gets A, then test2 gets B, and then test3 gets C, none of the tests can get the second lock + // they need and so they can also never give up the one they hold. + // + // This is known as the Dining Philosophers Problem [1] and a solution is to order locks before acquiring them. + // In the above example, test3 would start with trying to get A and, since it can't, block on that. Then test2 + // is free to continue and eventually release the locks. + // + // We implement the solution here by lexicographically sorting the locks by the (globally unique) name of the + // shared resource that each lock is (uniquely) associated with. + // + // [1] https://en.wikipedia.org/wiki/Dining_philosophers_problem + + List sharedAnnotations = findShared(executable); + List locks = sortedLocksForSharedResources(sharedAnnotations, extensionContext); + return invokeWithLocks(invocation, locks); + } + + private List sortedLocksForSharedResources(Collection sharedAnnotations, + ExtensionContext extensionContext) { + List sortedAnnotations = sharedAnnotations.stream().sorted(comparing(Shared::name)).collect(toList()); + List stores = // + sortedAnnotations + .stream() // + .map(shared -> scopedStore(extensionContext, shared.scope())) + .collect(toList()); + return IntStream + .range(0, sortedAnnotations.size()) // + .mapToObj(i -> findLockForShared(sortedAnnotations.get(i), stores.get(i))) + .collect(toList()); + } + + private Method testFactoryMethod(ExtensionContext extensionContext) { + return extensionContext + .getParent() + .orElseThrow(() -> new IllegalStateException( + "The parent extension context of a DynamicTest was not a @TestFactory-annotated test method")) + .getRequiredTestMethod(); + } + + private ExtensionContext.Store scopedStore(ExtensionContext extensionContext, Shared.Scope scope) { + ExtensionContext scopedContext = scopedContext(extensionContext, scope); + return scopedContext.getStore(NAMESPACE); + } + + private ExtensionContext scopedContext(ExtensionContext extensionContext, Shared.Scope scope) { + if (scope == Shared.Scope.SOURCE_FILE) { + ExtensionContext currentContext = extensionContext; + Optional parentContext = extensionContext.getParent(); + + while (parentContext.isPresent() && parentContext.get() != currentContext.getRoot()) { + currentContext = parentContext.get(); + parentContext = currentContext.getParent(); + } + + return currentContext; + } + + return extensionContext.getRoot(); + } + + private List findShared(Executable executable) { + return Arrays + .stream(executable.getParameters()) + .map(parameter -> AnnotationSupport.findAnnotation(parameter, Shared.class)) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(toList()); + } + + private void putNewLockForShared(Shared shared, ExtensionContext.Store store) { + store.getOrComputeIfAbsent(resourceLockKey(shared), __ -> new ReentrantLock(), ReentrantLock.class); + } + + private ReentrantLock findLockForShared(Shared shared, ExtensionContext.Store store) { + // @formatter:off + return Optional.ofNullable(store.get(resourceLockKey(shared), ReentrantLock.class)) + .orElseThrow(() -> { + String message = String.format("There should be a shared resource for the name %s", shared.name()); + return new IllegalStateException(message); + }); + // @formatter:on + } + + private T invokeWithLocks(Invocation invocation, List locks) throws Throwable { + locks.forEach(ReentrantLock::lock); + try { + return invocation.proceed(); + } + finally { + // for dining philosophers, "[t]he order in which each philosopher puts down the forks does not matter" + // (quote from Wikipedia) + locks.forEach(ReentrantLock::unlock); + } + } + +} diff --git a/src/main/java/org/junitpioneer/jupiter/resource/ResourceFactory.java b/src/main/java/org/junitpioneer/jupiter/resource/ResourceFactory.java new file mode 100644 index 000000000..87956a086 --- /dev/null +++ b/src/main/java/org/junitpioneer/jupiter/resource/ResourceFactory.java @@ -0,0 +1,54 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import java.util.List; + +import org.junit.jupiter.api.extension.ExtensionContext; + +/** + * {@code ResourceFactory} is the common interface for "resource factories", which are responsible + * for creating {@link Resource}s. + * + *

    It is part of the "resources" JUnit Jupiter extension, which pertains to anything that needs + * to be injected into tests and which may need to be started up or torn down. Temporary + * directories are a common example. + * + *

    This class is intended for implementors of new kinds of resources.

    + * + *

    For more details and examples, see + * the documentation on resources.

    + * + * @param the type of the resources created by the resource factory + * @since 1.9.0 + * @see Resource + */ +public interface ResourceFactory extends ExtensionContext.Store.CloseableResource { + + /** + * Returns a new resource. + * + * @param arguments a list of strings to be used to populate or configure the resource + * @throws Exception if creating the resource failed + */ + Resource create(List arguments) throws Exception; + + /** + * Closes the resource factory. + * + * @throws Exception if closing the resource factory failed + */ + @Override + default void close() throws Exception { + // no op by default + } + +} diff --git a/src/main/java/org/junitpioneer/jupiter/resource/Shared.java b/src/main/java/org/junitpioneer/jupiter/resource/Shared.java new file mode 100644 index 000000000..448bbe248 --- /dev/null +++ b/src/main/java/org/junitpioneer/jupiter/resource/Shared.java @@ -0,0 +1,92 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * {@code @Shared} is used to create a resource that is shared with multiple tests. + * + *

    It is part of the "resources" JUnit Jupiter extension, which pertains to anything that needs + * to be injected into tests and which may need to be started up or torn down. Temporary + * directories are a common example. + * + *

    This class is intended for users.

    + * + *

    For more details and examples, see + * the documentation on resources + * and temporary directories.

    + * + * @since 1.9.0 + * @see Resource + * @see ResourceFactory + * @see Scope + */ +@ExtendWith(ResourceExtension.class) +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) +public @interface Shared { + + /** + * The class of the resource factory to get the resource from. + */ + Class> factory(); + + /** + * The unique name of the resource. + */ + String name(); + + /** + * The scope for how long the resource will live. + * + *

    The default scope is {@link Shared.Scope#SOURCE_FILE}.

    + * + * @see Shared.Scope#SOURCE_FILE + * @see Shared.Scope#GLOBAL + */ + Scope scope() default Scope.SOURCE_FILE; + + /** + * {@code Scope} specifies how long a shared resource lives. + * + *

    It is part of the "resources" JUnit Jupiter extension, which pertains to anything that needs + * to be injected into tests and which may need to be started up or torn down. Temporary + * directories are a common example. + * + *

    For more details and examples, see + * the documentation on resources.

    + * + * @since 1.9.0 + * @see Resource + * @see ResourceFactory + * @see Shared + */ + enum Scope { + + /** + *

    At this scope, a shared resource will last as long as the entire test suite.

    + */ + GLOBAL, + + /** + *

    At this scope, a shared resource will last as long as the test file it is defined in.

    + */ + SOURCE_FILE + + } + +} diff --git a/src/main/java/org/junitpioneer/jupiter/resource/TemporaryDirectory.java b/src/main/java/org/junitpioneer/jupiter/resource/TemporaryDirectory.java new file mode 100644 index 000000000..7ff0b6477 --- /dev/null +++ b/src/main/java/org/junitpioneer/jupiter/resource/TemporaryDirectory.java @@ -0,0 +1,80 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import static java.util.Objects.requireNonNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * {@code TemporaryDirectory} is a "resource factory" implementation that, combined with + * {@link New @New} or {@link Shared @Shared}, allows for the creation of temporary directories + * that can be used by individual tests or shared across multiple tests. + * + *

    When used with the {@code @New} annotation and the annotation's {@code arguments} field is + * populated, the first argument will be used as the prefix of the name of the temporary + * directory.

    + * + *

    It is part of the "resources" JUnit Jupiter extension, which pertains to anything that needs + * to be injected into tests and which may need to be started up or torn down. Temporary + * directories are a common example.

    + * + *

    This class is intended for users.

    + * + *

    For more details and examples, see + * the documentation on resources + * and temporary directories.

    + * + * @since 1.9.0 + * @see ResourceFactory + * @see New + * @see Shared + */ +public final class TemporaryDirectory implements ResourceFactory { + + @Override + public Resource create(List arguments) throws Exception { + if (arguments.size() >= 2) { + throw new IllegalArgumentException("Expected 0 or 1 arguments, but got " + arguments.size()); + } + String directoryPrefix = (arguments.size() == 1) ? arguments.get(0) : ""; + requireNonNull(directoryPrefix, "Argument 0 can't be null"); + return new TemporaryDirectoryResource(Files.createTempDirectory(directoryPrefix)); + } + + private static final class TemporaryDirectoryResource implements Resource { + + private final Path tempDir; + + TemporaryDirectoryResource(Path tempDir) { + this.tempDir = tempDir; + } + + @Override + public Path get() { + return tempDir; + } + + @Override + public void close() throws Exception { + deleteRecursively(tempDir); + } + + private static void deleteRecursively(Path tempDir) throws IOException { + Files.walkFileTree(tempDir, PathDeleter.INSTANCE); + } + + } + +} diff --git a/src/main/java/org/junitpioneer/jupiter/resource/package-info.java b/src/main/java/org/junitpioneer/jupiter/resource/package-info.java new file mode 100644 index 000000000..d90aba206 --- /dev/null +++ b/src/main/java/org/junitpioneer/jupiter/resource/package-info.java @@ -0,0 +1,20 @@ +/** + * This package contains various classes pertaining to "resources": anything that needs to be injected into + * tests and which may need to be started up or torn down. A common example is a temporary directory. + * + *

    Check out the following types for details on the "temporary directory" extension: + *

      + *
    • {@link org.junitpioneer.jupiter.resource.TemporaryDirectory}
    • + *
    • {@link org.junitpioneer.jupiter.resource.Dir}
    • + *
    + * + *

    Check out the following types for details on resources in general: + *

      + *
    • {@link org.junitpioneer.jupiter.resource.Resource}
    • + *
    • {@link org.junitpioneer.jupiter.resource.ResourceFactory}
    • + *
    • {@link org.junitpioneer.jupiter.resource.New}
    • + *
    • {@link org.junitpioneer.jupiter.resource.Shared}
    • + *
    + */ + +package org.junitpioneer.jupiter.resource; diff --git a/src/main/module/module-info.java b/src/main/module/module-info.java index e6f7ef213..bd319609a 100644 --- a/src/main/module/module-info.java +++ b/src/main/module/module-info.java @@ -29,6 +29,7 @@ opens org.junitpioneer.jupiter.cartesian to org.junit.platform.commons; opens org.junitpioneer.jupiter.issue to org.junit.platform.commons; opens org.junitpioneer.jupiter.params to org.junit.platform.commons; + opens org.junitpioneer.jupiter.resource to org.junit.platform.commons; opens org.junitpioneer.jupiter.json to org.junit.platform.commons, com.fasterxml.jackson.databind; provides org.junit.platform.launcher.TestExecutionListener diff --git a/src/test/java/org/junitpioneer/jupiter/resource/PathDeleterTests.java b/src/test/java/org/junitpioneer/jupiter/resource/PathDeleterTests.java new file mode 100644 index 000000000..13eafbeaf --- /dev/null +++ b/src/test/java/org/junitpioneer/jupiter/resource/PathDeleterTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; + +import org.junit.jupiter.api.Test; + +class PathDeleterTests { + + @Test + void deletesFile() throws IOException { + try (FileSystem fileSystem = Jimfs.newFileSystem(Configuration.unix())) { + Path file = fileSystem.getPath("file.txt"); + Files.createFile(file); + + Files.walkFileTree(file, PathDeleter.INSTANCE); + + assertThat(file).doesNotExist(); + } + } + + @Test + void deletingNonExistentFileProducesNoIOException() throws IOException { + try (FileSystem fileSystem = Jimfs.newFileSystem(Configuration.unix())) { + assertDoesNotThrow( + () -> PathDeleter.INSTANCE.visitFile(fileSystem.getPath("some", "arbitrary", "file.txt"), null)); + } + } + + @Test + void deletesEmptyDirectory() throws IOException { + try (FileSystem fileSystem = Jimfs.newFileSystem(Configuration.unix())) { + Path dir = fileSystem.getPath("dir"); + Files.createDirectories(dir); + + Files.walkFileTree(dir, PathDeleter.INSTANCE); + + assertThat(dir).doesNotExist(); + } + } + + @Test + void deletesNonEmptyDirectory() throws IOException { + try (FileSystem fileSystem = Jimfs.newFileSystem(Configuration.unix())) { + Path dir = fileSystem.getPath("dir"); + Path file = dir.resolve("file.txt"); + Files.createDirectories(dir); + Files.createFile(file); + + Files.walkFileTree(dir, PathDeleter.INSTANCE); + + assertThat(file).doesNotExist(); + assertThat(dir).doesNotExist(); + } + } + + @Test + void deletingNonExistentDirectoryProducesNoIOException() throws IOException { + try (FileSystem fileSystem = Jimfs.newFileSystem(Configuration.unix())) { + assertDoesNotThrow(() -> PathDeleter.INSTANCE + .postVisitDirectory(fileSystem.getPath("some", "arbitrary", "directory"), null)); + } + } + +} diff --git a/src/test/java/org/junitpioneer/jupiter/resource/ResourcesParallelismTests.java b/src/test/java/org/junitpioneer/jupiter/resource/ResourcesParallelismTests.java new file mode 100644 index 000000000..5107e487c --- /dev/null +++ b/src/test/java/org/junitpioneer/jupiter/resource/ResourcesParallelismTests.java @@ -0,0 +1,516 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD; +import static org.junitpioneer.jupiter.resource.Shared.Scope.GLOBAL; +import static org.junitpioneer.testkit.assertion.PioneerAssert.assertThat; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junitpioneer.testkit.ExecutionResults; +import org.junitpioneer.testkit.PioneerTestKit; + +class ResourcesParallelismTests { + + /* + * Asserts that parallel tests don't concurrently access shared resources. + * See `ResourceExtension::runSequentially` for details. + */ + + @DisplayName("when a number of shared resources are used in the same test suite") + @Nested + class WhenANumberOfSharedResourcesAreUsedInSameTestSuiteTests { + + @DisplayName("then the tests do not run in parallel") + @Execution(SAME_THREAD) + @Test + void thenTestsDoNotRunInParallel() { + ExecutionResults executionResults = assertTimeoutPreemptively(Duration.ofSeconds(15), + () -> PioneerTestKit.executeTestClass(ThrowIfTestsRunInParallelTestCases.class), + "The tests in ThrowIfTestsRunInParallelTestCases became deadlocked!"); + assertThat(executionResults).hasNumberOfSucceededTests(3); + } + + @DisplayName("then the test factories do not run in parallel") + @Execution(SAME_THREAD) + @Test + void thenTestFactoriesDoNotRunInParallel() { + ExecutionResults executionResults = assertTimeoutPreemptively(Duration.ofSeconds(15), + () -> PioneerTestKit.executeTestClass(ThrowIfTestFactoriesRunInParallelTestCases.class), + "The tests in ThrowIfTestFactoriesRunInParallelTestCases became deadlocked!"); + assertThat(executionResults).hasNumberOfSucceededTests(9); + } + + @DisplayName("then the test templates do not run in parallel") + @Execution(SAME_THREAD) + @Test + void thenTestTemplatesDoNotRunInParallel() { + ExecutionResults executionResults = assertTimeoutPreemptively(Duration.ofSeconds(15), + () -> PioneerTestKit.executeTestClass(ThrowIfTestTemplatesRunInParallelTestCases.class), + "The tests in ThrowIfTestTemplatesRunInParallelTestCases became deadlocked!"); + assertThat(executionResults).hasNumberOfSucceededTests(9); + } + + @DisplayName("then the test class constructors do not run in parallel") + @Execution(SAME_THREAD) + @Test + void thenTestClassConstructorsDoNotRunInParallel() { + ExecutionResults executionResults = assertTimeoutPreemptively(Duration.ofSeconds(15), + () -> PioneerTestKit + .executeTestClasses(asList(ThrowIfTestClassConstructorsRunInParallelTestCases1.class, + ThrowIfTestClassConstructorsRunInParallelTestCases2.class, + ThrowIfTestClassConstructorsRunInParallelTestCases3.class)), + "The tests in ThrowIfTestTemplatesRunInParallelTestCases(1|2|3) became deadlocked!"); + assertThat(executionResults).hasNumberOfSucceededTests(3); + } + + @DisplayName("then the @BeforeEach methods do not run in parallel") + @Execution(SAME_THREAD) + @Test + void thenBeforeEachMethodsDoNotRunInParallel() { + ExecutionResults executionResults = assertTimeoutPreemptively(Duration.ofSeconds(15), + () -> PioneerTestKit.executeTestClass(ThrowIfBeforeEachMethodsRunInParallelTestCases.class), + "The tests in ThrowIfBeforeEachMethodsRunInParallelTestCases became deadlocked!"); + assertThat(executionResults).hasNumberOfSucceededTests(3); + } + + @DisplayName("then the @AfterEach methods do not run in parallel") + @Execution(SAME_THREAD) + @Test + void thenAfterEachMethodsDoNotRunInParallel() { + ExecutionResults executionResults = assertTimeoutPreemptively(Duration.ofSeconds(15), + () -> PioneerTestKit.executeTestClass(ThrowIfAfterEachMethodsRunInParallelTestCases.class), + "The tests in ThrowIfAfterEachMethodsRunInParallelTestCases became deadlocked!"); + assertThat(executionResults).hasNumberOfSucceededTests(3); + } + + @DisplayName("then the @BeforeAll methods do not run in parallel") + @Execution(SAME_THREAD) + @Test + void thenBeforeAllMethodsDoNotRunInParallel() { + ExecutionResults executionResults = assertTimeoutPreemptively(Duration.ofSeconds(15_000), + () -> PioneerTestKit.executeTestClass(ThrowIfBeforeAllMethodsRunInParallelTestCases.class), + "The tests in ThrowIfBeforeAllMethodsRunInParallelTestCases became deadlocked!"); + assertThat(executionResults).hasNumberOfSucceededTests(3); + } + + @DisplayName("then the @AfterAll methods do not run in parallel") + @Execution(SAME_THREAD) + @Test + void thenAfterAllMethodsDoNotRunInParallel() { + ExecutionResults executionResults = assertTimeoutPreemptively(Duration.ofSeconds(15), + () -> PioneerTestKit.executeTestClass(ThrowIfAfterAllMethodsRunInParallelTestCases.class), + "The tests in ThrowIfAfterAllMethodsRunInParallelTestCases became deadlocked!"); + assertThat(executionResults).hasNumberOfSucceededTests(3); + } + + } + + private static final AtomicInteger COUNTER = new AtomicInteger(0); + private static final int TIMEOUT_MILLIS = 100; + private static final String SHARED_RESOURCE_A_NAME = "shared-resource-a"; + private static final String SHARED_RESOURCE_B_NAME = "shared-resource-b"; + private static final String SHARED_RESOURCE_C_NAME = "shared-resource-c"; + + static class ThrowIfTestsRunInParallelTestCases { + + @Test + void test1( + // we don't actually use the resources, we just have them injected to verify whether sharing the + // same resources prevent the tests from running in parallel + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB) + throws Exception { + failIfExecutedInParallel("test1"); + } + + @Test + void test2( + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC) + throws Exception { + failIfExecutedInParallel("test2"); + } + + @Test + void test3( + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA) + throws Exception { + failIfExecutedInParallel("test3"); + } + + } + + static class ThrowIfTestFactoriesRunInParallelTestCases { + + @TestFactory + Stream test1( + // we don't actually use the resources, we just have them injected to verify whether sharing the + // same resources prevent the tests from running in parallel + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB) + throws Exception { + failIfExecutedInParallel("test1"); + return DynamicTest + .stream(Stream.of("DynamicTest1", "DynamicTest2", "DynamicTest3"), name -> "test1" + name, + ResourcesParallelismTests::failIfExecutedInParallel); + } + + @TestFactory + Stream test2( + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC) + throws Exception { + failIfExecutedInParallel("test2"); + return DynamicTest + .stream(Stream.of("DynamicTest1", "DynamicTest2", "DynamicTest3"), name -> "test2" + name, + ResourcesParallelismTests::failIfExecutedInParallel); + } + + @TestFactory + Stream test3( + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA) + throws Exception { + failIfExecutedInParallel("test3"); + return DynamicTest + .stream(Stream.of("DynamicTest1", "DynamicTest2", "DynamicTest3"), name -> "test3" + name, + ResourcesParallelismTests::failIfExecutedInParallel); + } + + } + + static class ThrowIfTestTemplatesRunInParallelTestCases { + + @ParameterizedTest + @ValueSource(ints = { 1, 2, 3 }) + void test1(@SuppressWarnings("unused") int iteration, + // we don't actually use the resources, we just have them injected to verify whether sharing the + // same resources prevent the tests from running in parallel + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB) + throws Exception { + failIfExecutedInParallel("test1Iteration" + iteration); + } + + @ParameterizedTest + @ValueSource(ints = { 1, 2, 3 }) + void test2(@SuppressWarnings("unused") int iteration, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC) + throws Exception { + failIfExecutedInParallel("test2Iteration" + iteration); + } + + @ParameterizedTest + @ValueSource(ints = { 1, 2, 3 }) + void test3(@SuppressWarnings("unused") int iteration, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA) + throws Exception { + failIfExecutedInParallel("test3Iteration" + iteration); + } + + } + + static class ThrowIfTestClassConstructorsRunInParallelTestCases1 { + + ThrowIfTestClassConstructorsRunInParallelTestCases1( + // we don't actually use the resources, we just have them injected to verify whether sharing the + // same resources prevent the test constructors from running in parallel + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME, scope = GLOBAL) Path directoryA, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME, scope = GLOBAL) Path directoryB) + throws Exception { + failIfExecutedInParallel("testConstructor1"); + } + + @Test + void fakeTest() { + } + + } + + static class ThrowIfTestClassConstructorsRunInParallelTestCases2 { + + ThrowIfTestClassConstructorsRunInParallelTestCases2( + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME, scope = GLOBAL) Path directoryB, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME, scope = GLOBAL) Path directoryC) + throws Exception { + failIfExecutedInParallel("testConstructor2"); + } + + @Test + void fakeTest() { + } + + } + + static class ThrowIfTestClassConstructorsRunInParallelTestCases3 { + + ThrowIfTestClassConstructorsRunInParallelTestCases3( + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME, scope = GLOBAL) Path directoryC, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME, scope = GLOBAL) Path directoryA) + throws Exception { + failIfExecutedInParallel("testConstructor3"); + } + + @Test + void fakeTest() { + } + + } + + static class ThrowIfBeforeEachMethodsRunInParallelTestCases { + + @BeforeEach + void setup1(TestInfo testInfo, + // we don't actually use the resources, we just have them injected to verify whether sharing the + // same resources prevents the @BeforeEach methods from running multiple times in parallel + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB) + throws InterruptedException { + failIfExecutedInParallel("testBeforeEach1-" + testInfo.getTestMethod().get().getName()); + } + + @BeforeEach + void setup2(TestInfo testInfo, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC) + throws InterruptedException { + failIfExecutedInParallel("testBeforeEach2-" + testInfo.getTestMethod().get().getName()); + } + + @BeforeEach + void setup3(TestInfo testInfo, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA) + throws InterruptedException { + failIfExecutedInParallel("testBeforeEach3-" + testInfo.getTestMethod().get().getName()); + } + + @Test + void fakeTest1() { + } + + @Test + void fakeTest2() { + } + + @Test + void fakeTest3() { + } + + } + + static class ThrowIfAfterEachMethodsRunInParallelTestCases { + + @Test + void fakeTest1() { + } + + @Test + void fakeTest2() { + } + + @Test + void fakeTest3() { + } + + @AfterEach + void teardown1(TestInfo testInfo, + // we don't actually use the resources, we just have them injected to verify whether sharing the + // same resources prevents the @AfterEach methods from running multiple times in parallel + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB) + throws InterruptedException { + failIfExecutedInParallel("testAfterEach1-" + testInfo.getTestMethod().get().getName()); + } + + @AfterEach + void teardown2(TestInfo testInfo, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC) + throws InterruptedException { + failIfExecutedInParallel("testAfterEach2-" + testInfo.getTestMethod().get().getName()); + } + + @AfterEach + void teardown3(TestInfo testInfo, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA) + throws InterruptedException { + failIfExecutedInParallel("testAfterEach3-" + testInfo.getTestMethod().get().getName()); + } + + } + + static class ThrowIfBeforeAllMethodsRunInParallelTestCases { + + @BeforeAll + static void setup( + // we don't actually use the resources, we just have them injected to verify whether sharing the + // same resources prevents the @BeforeAll methods from running multiple times in parallel + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB) + throws InterruptedException { + failIfExecutedInParallel("testBeforeAll1"); + } + + @Test + void fakeTest() { + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class NestedTestCases1 { + + @BeforeAll + void setup( + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC) + throws InterruptedException { + failIfExecutedInParallel("testBeforeAll2"); + } + + @Test + void fakeTest() { + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class NestedTestCases2 { + + @BeforeAll + void setup( + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA) + throws InterruptedException { + failIfExecutedInParallel("testBeforeAll3"); + } + + @Test + void fakeTest() { + } + + } + + } + + } + + static class ThrowIfAfterAllMethodsRunInParallelTestCases { + + @Test + void fakeTest() { + } + + @AfterAll + static void teardown( + // we don't actually use the resources, we just have them injected to verify whether sharing the + // same resources prevents the @AfterAll methods from running multiple times in parallel + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB) + throws InterruptedException { + failIfExecutedInParallel("testAfterAll1"); + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class NestedTestCases1 { + + @Test + void fakeTest() { + } + + @AfterAll + void teardown( + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_B_NAME) Path directoryB, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC) + throws InterruptedException { + failIfExecutedInParallel("testAfterAll2"); + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class NestedTestCases2 { + + @Test + void fakeTest() { + } + + @AfterAll + void teardown( + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_C_NAME) Path directoryC, + @SuppressWarnings("unused") @Shared(factory = TemporaryDirectory.class, name = SHARED_RESOURCE_A_NAME) Path directoryA) + throws InterruptedException { + failIfExecutedInParallel("testAfterAll3"); + } + + } + + } + + } + + // this method is written to fail if it is executed at overlapping times in different threads + private static void failIfExecutedInParallel(String testName) throws InterruptedException { + try { + System.out.println(Thread.currentThread() + ": " + testName + ": COUNTER = " + COUNTER); + boolean wasZero = COUNTER.compareAndSet(0, 1); + assertThat(wasZero).isTrue(); + // wait for the next test to catch up and potentially fail + Thread.sleep(TIMEOUT_MILLIS); + System.out.println(Thread.currentThread() + ": " + testName + ": COUNTER = " + COUNTER); + boolean wasOne = COUNTER.compareAndSet(1, 2); + assertThat(wasOne).isTrue(); + // wait for the last test to catch up and potentially fail + Thread.sleep(TIMEOUT_MILLIS); + System.out.println(Thread.currentThread() + ": " + testName + ": COUNTER = " + COUNTER); + boolean wasTwo = COUNTER.compareAndSet(2, 0); + assertThat(wasTwo).isTrue(); + } + catch (AssertionError e) { + System.out.println(Thread.currentThread() + ": " + testName + ": e = " + stackTraceToString(e)); + throw e; + } + } + + private static String stackTraceToString(Throwable throwable) { + StringWriter writer = new StringWriter(); + throwable.printStackTrace(new PrintWriter(writer)); + return writer.toString(); + } + +} diff --git a/src/test/java/org/junitpioneer/jupiter/resource/ResourcesTests.java b/src/test/java/org/junitpioneer/jupiter/resource/ResourcesTests.java new file mode 100644 index 000000000..223688f16 --- /dev/null +++ b/src/test/java/org/junitpioneer/jupiter/resource/ResourcesTests.java @@ -0,0 +1,923 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.platform.testkit.engine.EventConditions.finished; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.cause; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.throwable; +import static org.junitpioneer.testkit.assertion.PioneerAssert.assertThat; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.net.UnknownHostException; +import java.nio.file.FileAlreadyExistsException; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junitpioneer.testkit.ExecutionResults; +import org.junitpioneer.testkit.PioneerTestKit; + +@DisplayName("Resources extension") +class ResourcesTests { + + @DisplayName("when a new resource factory is applied to a parameter") + @Nested + class WhenNewResourceFactoryAppliedToParameterTests { + + @DisplayName("then ::create is called") + @Test + void thenCreateIsCalled() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(CountingResourceFactory1TestCases.class); + assertThat(executionResults.testEvents().debug().succeeded().count()).isEqualTo(1); + assertThat(CountingResourceFactory1.createCalls).isEqualTo(1); + } + + @DisplayName("and the factory throws on ::create") + @Nested + class AndFactoryThrowsOnCreateTests { + + @DisplayName("then the thrown exception is wrapped and propagated") + @Test + void thenThrownExceptionIsWrappedAndPropagated() { + ExecutionResults executionResults = PioneerTestKit.executeTestClass(ThrowOnNewRFCreateTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), // + message("Unable to create a resource from `" + + ThrowOnRFCreateResourceFactory.class.getTypeName() + "`"), // + cause( // + instanceOf(EXPECTED_THROW_ON_RF_CREATE_EXCEPTION.getClass()), // + message(EXPECTED_THROW_ON_RF_CREATE_EXCEPTION.getMessage()))))); + } + + } + + @DisplayName("and the factory throws on ::close") + @Nested + class AndFactoryThrowsOnCloseTests { + + @DisplayName("then the thrown exception is propagated") + @Test + void thenThrownExceptionIsPropagated() { + ExecutionResults executionResults = PioneerTestKit.executeTestClass(ThrowOnNewRFCloseTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(EXPECTED_THROW_ON_RF_CLOSE_EXCEPTION.getClass()), // + message(EXPECTED_THROW_ON_RF_CLOSE_EXCEPTION.getMessage())))); + } + + } + + @DisplayName("and the factory returns null on ::create") + @Nested + class AndFactoryReturnsNullOnCreateTests { + + @DisplayName("then a proper exception is thrown") + @Test + void thenProperExceptionIsThrown() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(NewRFCreateReturnsNullTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), // + message(message -> message.matches(".*`Resource` instance.*was null.*"))))); + } + + } + + @DisplayName("and a resource is created") + @Nested + class AndResourceIsCreatedTests { + + @DisplayName("and the resource throws on ::get") + @Nested + class AndResourceThrowsOnGetTests { + + @DisplayName("then the thrown exception is wrapped and propagated") + @Test + void thenThrownExceptionIsWrappedAndPropagated() { + ExecutionResults executionResults = PioneerTestKit.executeTestClass(ThrowOnNewRGetTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), // + message("Unable to get the contents of the resource created by `" + + ThrowOnRGetResourceFactory.class.getTypeName() + "`"), // + cause( // + instanceOf(EXPECTED_THROW_ON_R_GET_EXCEPTION.getClass()), // + message(EXPECTED_THROW_ON_R_GET_EXCEPTION.getMessage()))))); + } + + } + + @DisplayName("and the resource throws on ::close") + @Nested + class AndResourceThrowsOnCloseTests { + + @DisplayName("then the thrown exception is propagated") + @Test + void thenThrownExceptionIsWrappedAndPropagated() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(ThrowOnNewRCloseTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(EXPECTED_THROW_ON_R_CLOSE_EXCEPTION.getClass()), // + message(EXPECTED_THROW_ON_R_CLOSE_EXCEPTION.getMessage())))); + } + + } + + @DisplayName("and the resource returns null on ::get") + @Nested + class AndResourceReturnsNullOnGetTests { + + @DisplayName("then a proper exception is thrown") + @Test + void thenProperExceptionIsThrown() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(NewRGetReturnsNullTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), + message(message -> message.matches(".*resource.*was null.*"))))); + } + + } + + } + + } + + static class CountingResourceFactory1TestCases { + + @Test + @SuppressWarnings("unused") + void test(@New(CountingResourceFactory1.class) Object object) { + + } + + } + + static final class CountingResourceFactory1 implements ResourceFactory { + + static int createCalls = 0; + + @Override + public Resource create(List arguments) { + createCalls++; + return () -> "some resource"; + } + + } + + static class ThrowOnNewRFCreateTestCases { + + @Test + @SuppressWarnings("unused") + void test(@New(ThrowOnRFCreateResourceFactory.class) Object object) { + + } + + } + + static final class ThrowOnRFCreateResourceFactory implements ResourceFactory { + + @Override + public Resource create(List arguments) throws Exception { + throw EXPECTED_THROW_ON_RF_CREATE_EXCEPTION; + } + + } + + private static final Exception EXPECTED_THROW_ON_RF_CREATE_EXCEPTION = new IOException( + "Failed to connect to the Matrix"); + + static class ThrowOnNewRFCloseTestCases { + + @Test + @SuppressWarnings("unused") + void test(@New(ThrowOnRFCloseResourceFactory.class) Object object) { + + } + + } + + static final class ThrowOnRFCloseResourceFactory implements ResourceFactory { + + @Override + public Resource create(List arguments) { + return () -> "foo"; + } + + @Override + public void close() throws Exception { + throw EXPECTED_THROW_ON_RF_CLOSE_EXCEPTION; + } + + } + + private static final Exception EXPECTED_THROW_ON_RF_CLOSE_EXCEPTION = new CloneNotSupportedException( + "Failed to clone a homunculus"); + + static class NewRFCreateReturnsNullTestCases { + + @Test + @SuppressWarnings("unused") + void test(@New(value = RFCreateReturnsNullResourceFactory.class, arguments = { "some-arg" }) Object object) { + + } + + } + + static final class RFCreateReturnsNullResourceFactory implements ResourceFactory { + + @Override + public Resource create(List arguments) { + return null; + } + + } + + static class ThrowOnNewRGetTestCases { + + @Test + @SuppressWarnings("unused") + void test(@New(ThrowOnRGetResourceFactory.class) Object object) { + + } + + } + + static final class ThrowOnRGetResourceFactory implements ResourceFactory { + + @Override + public Resource create(List arguments) { + return new ThrowOnRGetResource(); + } + + } + + static final class ThrowOnRGetResource implements Resource { + + @Override + public Object get() throws Exception { + throw EXPECTED_THROW_ON_R_GET_EXCEPTION; + } + + } + + private static final Exception EXPECTED_THROW_ON_R_GET_EXCEPTION = new FileAlreadyExistsException( + "Wait, what's that file doing there?"); + + static class ThrowOnNewRCloseTestCases { + + @Test + @SuppressWarnings("unused") + void test(@New(ThrowOnRCloseResourceFactory.class) Object object) { + + } + + } + + static final class ThrowOnRCloseResourceFactory implements ResourceFactory { + + @Override + public Resource create(List arguments) { + return new ThrowOnRCloseResource(); + } + + } + + static final class ThrowOnRCloseResource implements Resource { + + @Override + public Object get() { + return "foo"; + } + + @Override + public void close() throws Exception { + throw EXPECTED_THROW_ON_R_CLOSE_EXCEPTION; + } + + } + + private static final Exception EXPECTED_THROW_ON_R_CLOSE_EXCEPTION = new UnknownHostException( + "Wait, where's the Internet gone?!"); + + static class NewRGetReturnsNullTestCases { + + @Test + @SuppressWarnings("unused") + void test(@New(RGetReturnsNullResourceFactory.class) Object object) { + + } + + } + + static final class RGetReturnsNullResourceFactory implements ResourceFactory { + + @Override + public Resource create(List arguments) { + return new RGetReturnsNullResource(); + } + + } + + static final class RGetReturnsNullResource implements Resource { + + @Override + public Object get() { + return null; + } + + } + + // --- + + @DisplayName("when a test class has a test method with a parameter annotated with both @New and @Shared") + @Nested + class WhenTestClassHasTestMethodWithParameterAnnotatedWithBothNewAndSharedTests { + + @DisplayName("then an exception is thrown") + @Test + void thenExceptionIsThrown() throws Exception { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(TestMethodWithParameterAnnotatedWithBothNewAndSharedTestCases.class); + Method failingTest = TestMethodWithParameterAnnotatedWithBothNewAndSharedTestCases.class + .getDeclaredMethod("test", String.class); + assertThat(executionResults) + .hasSingleFailedTest() + .withExceptionInstanceOf(ParameterResolutionException.class) + .hasMessage("Parameter [%s] in method [%s] is annotated with both @New and @Shared", + failingTest.getParameters()[0], failingTest); + } + + } + + static class TestMethodWithParameterAnnotatedWithBothNewAndSharedTestCases { + + @Test + void test( + @New(DummyResourceFactory.class) @Shared(factory = DummyResourceFactory.class, name = "some-name") String param) { + fail("We should not get this far."); + } + + } + + static final class DummyResourceFactory implements ResourceFactory { + + @Override + public Resource create(List arguments) { + return () -> "dummy"; + } + + } + + // --- + + @DisplayName("when a parameter is annotated with @Shared, and another parameter is annotated with @Shared with " + + "the same name but a different factory type") + @Nested + class WhenParameterIsAnnotatedWithSharedAndAnotherParamIsAnnotatedWithSharedWithSameNameButDifferentFactoryTypeTests { + + @DisplayName("then it throws an exception") + @Test + void thenItThrowsAnException() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(SingleTestMethodWithParamsWithSharedSameNameButDifferentTypesTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), // + message(String + .format( + "Two or more parameters are annotated with @Shared annotations with the " + + "name \"%s\" but with different factory classes", + "some-name"))))); + + executionResults = PioneerTestKit + .executeTestClass(TwoTestMethodsWithParamsWithSharedSameNameButDifferentTypesTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), // + message(String + .format( + "Two or more parameters are annotated with @Shared annotations with the " + + "name \"%s\" but with different factory classes", + "some-name"))))); + } + + } + + static class SingleTestMethodWithParamsWithSharedSameNameButDifferentTypesTestCases { + + @Test + void test(@Shared(factory = DummyResourceFactory.class, name = "some-name") String first, + @Shared(factory = OtherResourceFactory.class, name = "some-name") String second) { + + } + + } + + static class TwoTestMethodsWithParamsWithSharedSameNameButDifferentTypesTestCases { + + @Test + void test_1(@Shared(factory = DummyResourceFactory.class, name = "some-name") String foo) { + + } + + @Test + void test_2(@Shared(factory = OtherResourceFactory.class, name = "some-name") String bar) { + + } + + } + + static final class OtherResourceFactory implements ResourceFactory { + + @Override + public Resource create(List arguments) { + return () -> null; + } + + } + + // --- + + @DisplayName("when a parameter is annotated with @Shared, and another parameter is annotated with @Shared with " + + "the same name but a different scope") + @Nested + class WhenParameterIsAnnotatedWithSharedAndAnotherParamIsAnnotatedWithSharedWithSameNameButDifferentScopeTests { + + @DisplayName("then it throws an exception") + @Test + void thenItThrowsAnException() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(TwoTestMethodsWithParamsWithSharedSameNameButDifferentScopesTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), // + message(String + .format("Two or more parameters are annotated with @Shared annotations with " + + "the name \"%s\" but with different scopes", + "some-name-1"))))); + + executionResults = PioneerTestKit + .executeTestClasses(asList(TestMethodWithParamsWithSharedSameNameButDifferentScopesTestCases1.class, + TestMethodWithParamsWithSharedSameNameButDifferentScopesTestCases2.class)); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), // + message(String + .format("Two or more parameters are annotated with @Shared annotations with " + + "the name \"%s\" but with different scopes", + "some-name-2"))))); + } + + } + + static class TwoTestMethodsWithParamsWithSharedSameNameButDifferentScopesTestCases { + + @Test + void test_1( + @Shared(factory = DummyResourceFactory.class, name = "some-name-1", scope = Shared.Scope.GLOBAL) String first) { + + } + + @Test + void test_2( + @Shared(factory = DummyResourceFactory.class, name = "some-name-1", scope = Shared.Scope.SOURCE_FILE) String second) { + + } + + } + + static class TestMethodWithParamsWithSharedSameNameButDifferentScopesTestCases1 { + + @Test + void test( + @Shared(factory = DummyResourceFactory.class, name = "some-name-2", scope = Shared.Scope.GLOBAL) String first) { + + } + + } + + static class TestMethodWithParamsWithSharedSameNameButDifferentScopesTestCases2 { + + @Test + void test( + @Shared(factory = DummyResourceFactory.class, name = "some-name-2", scope = Shared.Scope.SOURCE_FILE) String second) { + + } + + } + + // --- + + @DisplayName("when a test method has two parameters annotated with " + + "@Shared(factory = DummyResourceFactory.class, name = \"some-name\")") + @Nested + class WhenTestMethodHasTwoParamsAnnotatedWithSharedAnnotationWithSameFactoryAndNameTests { + + @DisplayName("then it throws an exception") + @Test + void thenItThrowsAnException() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(TestMethodWithTwoParamsWithSameSharedAnnotationTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), // + message("A test method has 2 parameters annotated with @Shared with the same " + + "factory type and name; this is redundant, so it is not allowed")))); + } + + } + + static class TestMethodWithTwoParamsWithSameSharedAnnotationTestCases { + + @Test + void test(@Shared(factory = DummyResourceFactory.class, name = "some-name") String first, + @Shared(factory = DummyResourceFactory.class, name = "some-name") String second) { + + } + + } + + // --- + + @DisplayName("when a shared resource factory is applied to a parameter") + @Nested + class WhenSharedResourceFactoryAppliedToParameterTests { + + @DisplayName("then ::create is called") + @Test + void thenCreateIsCalled() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(CountingResourceFactory2TestCases.class); + assertThat(executionResults.testEvents().debug().succeeded().count()).isEqualTo(1); + } + + @DisplayName("and the factory throws on ::create") + @Nested + class AndFactoryThrowsOnCreateTests { + + @DisplayName("then the thrown exception is wrapped and propagated") + @Test + void thenThrownExceptionIsWrappedAndPropagated() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(ThrowOnSharedRFCreateTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), // + message("Unable to create a resource from `" + + ThrowOnRFCreateResourceFactory.class.getTypeName() + "`"), // + cause( // + instanceOf(EXPECTED_THROW_ON_RF_CREATE_EXCEPTION.getClass()), // + message(EXPECTED_THROW_ON_RF_CREATE_EXCEPTION.getMessage()))))); + } + + } + + @DisplayName("and the factory throws on ::close") + @Nested + class AndFactoryThrowsOnCloseTests { + + @DisplayName("then the thrown exception is propagated") + @Test + void thenThrownExceptionIsPropagated() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(ThrowOnSharedRFCloseTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(EXPECTED_THROW_ON_RF_CLOSE_EXCEPTION.getClass()), // + message(EXPECTED_THROW_ON_RF_CLOSE_EXCEPTION.getMessage())))); + } + + } + + @DisplayName("and the factory returns null on ::create") + @Nested + class AndFactoryReturnsNullOnCreateTests { + + @DisplayName("then a proper exception is thrown") + @Test + void thenProperExceptionIsThrown() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(SharedRFCreateReturnsNullTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), // + message(message -> message.matches(".*`Resource` instance.*was null.*"))))); + } + + } + + @DisplayName("and a resource is created") + @Nested + class AndResourceIsCreatedTests { + + @DisplayName("and the resource throws on ::get") + @Nested + class AndResourceThrowsOnGetTests { + + @DisplayName("then the thrown exception is wrapped and propagated") + @Test + void thenThrownExceptionIsWrappedAndPropagated() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(ThrowOnSharedRGetTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), // + message("Unable to get the contents of the resource created by `" + + ThrowOnRGetResourceFactory.class + "`"), // + cause( // + instanceOf(EXPECTED_THROW_ON_R_GET_EXCEPTION.getClass()), // + message(EXPECTED_THROW_ON_R_GET_EXCEPTION.getMessage()))))); + } + + } + + @DisplayName("and the resource throws on ::close") + @Nested + class AndResourceThrowsOnCloseTests { + + @DisplayName("then the thrown exception is propagated") + @Test + void thenThrownExceptionIsWrappedAndPropagated() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(ThrowOnSharedRCloseTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(EXPECTED_THROW_ON_R_CLOSE_EXCEPTION.getClass()), // + message(EXPECTED_THROW_ON_R_CLOSE_EXCEPTION.getMessage())))); + } + + } + + @DisplayName("and the resource returns null on ::get") + @Nested + class AndResourceReturnsNullOnGetTests { + + @DisplayName("then a proper exception is thrown") + @Test + void thenProperExceptionIsThrown() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(SharedRGetReturnsNullTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly( // + 1, // + finished( // + throwable( // + instanceOf(ParameterResolutionException.class), // + message(message -> message.matches(".*resource.*was null.*"))))); + } + + } + + } + + } + + static class CountingResourceFactory2TestCases { + + @Test + @SuppressWarnings("unused") + void test(@Shared(factory = CountingResourceFactory2.class, name = "some-name") Object object) { + + } + + } + + static final class CountingResourceFactory2 implements ResourceFactory { + + static int createCalls = 0; + + @Override + public Resource create(List arguments) { + createCalls++; + return () -> "some resource"; + } + + } + + static class ThrowOnSharedRFCreateTestCases { + + @Test + @SuppressWarnings("unused") + void test(@Shared(factory = ThrowOnRFCreateResourceFactory.class, name = "some-name") Object object) { + + } + + } + + static class ThrowOnSharedRFCloseTestCases { + + @Test + @SuppressWarnings("unused") + void test(@Shared(factory = ThrowOnRFCloseResourceFactory.class, name = "some-name") Object object) { + + } + + } + + static class ThrowOnSharedRGetTestCases { + + @Test + @SuppressWarnings("unused") + void test(@Shared(factory = ThrowOnRGetResourceFactory.class, name = "some-name") Object object) { + + } + + } + + static class ThrowOnSharedRCloseTestCases { + + @Test + @SuppressWarnings("unused") + void test(@Shared(factory = ThrowOnRCloseResourceFactory.class, name = "some-name") Object object) { + + } + + } + + static class SharedRFCreateReturnsNullTestCases { + + @Test + @SuppressWarnings("unused") + void test(@Shared(name = "foo", factory = RFCreateReturnsNullResourceFactory.class) Object object) { + + } + + } + + static class SharedRGetReturnsNullTestCases { + + @Test + @SuppressWarnings("unused") + void test(@Shared(name = "foo", factory = RGetReturnsNullResourceFactory.class) Object object) { + + } + + } + + // --- + + @DisplayName("when a shared resource factory is applied to two parameters") + @Nested + class WhenSharedResourceFactoryAppliedToTwoParametersTests { + + @DisplayName("then ::create is called only once") + @Test + void thenCreateIsCalledOnlyOnce() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(CountingResourceFactory3TestCases.class); + assertThat(executionResults.testEvents().debug().succeeded().count()).isEqualTo(2); + assertThat(CountingResourceFactory3.createCalls).isEqualTo(1); + } + + } + + static class CountingResourceFactory3TestCases { + + @Test + @SuppressWarnings("unused") + void test_1(@Shared(factory = CountingResourceFactory3.class, name = "some-name") Object object) { + + } + + @Test + @SuppressWarnings("unused") + void test_2(@Shared(factory = CountingResourceFactory3.class, name = "some-name") Object object) { + + } + + } + + static final class CountingResourceFactory3 implements ResourceFactory { + + static int createCalls = 0; + + @Override + public Resource create(List arguments) { + createCalls++; + return () -> "some resource"; + } + + } + +} diff --git a/src/test/java/org/junitpioneer/jupiter/resource/TemporaryDirectoryDirTests.java b/src/test/java/org/junitpioneer/jupiter/resource/TemporaryDirectoryDirTests.java new file mode 100644 index 000000000..560407eaf --- /dev/null +++ b/src/test/java/org/junitpioneer/jupiter/resource/TemporaryDirectoryDirTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import static org.junitpioneer.jupiter.resource.TemporaryDirectoryTests.ROOT_TEMP_DIR; +import static org.junitpioneer.testkit.assertion.PioneerAssert.assertThat; + +import java.nio.file.Path; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junitpioneer.testkit.ExecutionResults; +import org.junitpioneer.testkit.PioneerTestKit; + +class TemporaryDirectoryDirTests { + + @Nested + @DisplayName("when a test class has a test method with a @Dir-annotated parameter") + class WhenTestClassHasTestMethodWithDirParameterTests { + + @Test + @DisplayName("then the parameter is populated with a new readable and writeable temporary directory " + + "that lasts as long as the test") + void thenParameterIsPopulatedWithNewReadableAndWriteableTempDirThatLastsAsLongAsTheTest() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(SingleTestMethodWithDirParameterTestCases.class); + assertThat(executionResults).hasSingleSucceededTest(); + assertThat(SingleTestMethodWithDirParameterTestCases.recordedPath).doesNotExist(); + } + + } + + static class SingleTestMethodWithDirParameterTestCases { + + static Path recordedPath; + + @Test + void theTest(@Dir Path tempDir) { + assertThat(tempDir).isEmptyDirectory().startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + assertThat(tempDir).canReadAndWriteFile(); + + recordedPath = tempDir; + } + + } + +} diff --git a/src/test/java/org/junitpioneer/jupiter/resource/TemporaryDirectoryTests.java b/src/test/java/org/junitpioneer/jupiter/resource/TemporaryDirectoryTests.java new file mode 100644 index 000000000..5743a4147 --- /dev/null +++ b/src/test/java/org/junitpioneer/jupiter/resource/TemporaryDirectoryTests.java @@ -0,0 +1,552 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter.resource; + +import static java.util.Arrays.asList; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.platform.testkit.engine.EventConditions.finished; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.cause; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.throwable; +import static org.junitpioneer.jupiter.resource.Shared.Scope.GLOBAL; +import static org.junitpioneer.jupiter.resource.Shared.Scope.SOURCE_FILE; +import static org.junitpioneer.testkit.assertion.PioneerAssert.assertThat; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junitpioneer.testkit.ExecutionResults; +import org.junitpioneer.testkit.PioneerTestKit; + +@DisplayName("Resources extension") +class TemporaryDirectoryTests { + + static final Path ROOT_TEMP_DIR = Paths.get(System.getProperty("java.io.tmpdir")); + + @DisplayName("when a test class has a test method with a @New(TemporaryDirectory.class)-annotated parameter") + @Nested + class WhenTestClassHasTestMethodWithNewTempDirParameterTests { + + @DisplayName("then the parameter is populated with a new readable and writeable temporary directory " + + "that lasts as long as the test") + @Test + void thenParameterIsPopulatedWithNewReadableAndWriteableTempDirThatLastsAsLongAsTheTest() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(SingleTestMethodWithNewTempDirParameterTestCases.class); + assertThat(executionResults).hasSingleSucceededTest(); + assertThat(SingleTestMethodWithNewTempDirParameterTestCases.recordedPath).doesNotExist(); + } + + } + + static class SingleTestMethodWithNewTempDirParameterTestCases { + + static Path recordedPath; + + @Test + void theTest(@New(TemporaryDirectory.class) Path tempDir) { + assertThat(tempDir).isEmptyDirectory().startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + assertThat(tempDir).canReadAndWriteFile(); + + recordedPath = tempDir; + } + + } + + // --- + + @DisplayName("when a test class has a test method with a parameter annotated with " + + "@New(value = TemporaryDirectory.class, arguments = {\"tempDirPrefix\"}") + @Nested + class WhenTestClassHasTestMethodWithParameterAnnotatedWithNewTempDirWithArgTests { + + @DisplayName("then the parameter is populated with a new temporary directory " + + "that has the prefix \"tempDirPrefix\"") + @Test + void thenParameterIsPopulatedWithNewTempDirWithSuffixEquallingArg() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(SingleTestMethodWithParameterWithNewTempDirAndArgTestCases.class); + assertThat(executionResults).hasSingleSucceededTest(); + } + + } + + static class SingleTestMethodWithParameterWithNewTempDirAndArgTestCases { + + @Test + void theTest(@New(value = TemporaryDirectory.class, arguments = { "tempDirPrefix" }) Path tempDir) { + assertThat(ROOT_TEMP_DIR.relativize(tempDir)).asString().startsWith("tempDirPrefix"); + } + + } + + // --- + + @DisplayName("when a test class has multiple test methods with a @New(TemporaryDirectory.class)-annotated parameter") + @Nested + class WhenTestClassHasMultipleTestMethodsWithNewTempDirAnnotatedParameterTests { + + @DisplayName("then the parameters are populated with new readable and writeable " + + "temporary directories that are torn down afterwards") + @Test + void thenParametersArePopulatedWithNewReadableAndWriteableTempDirsThatAreTornDownAfterwards() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(TwoTestMethodsWithNewTempDirParameterTestCases.class); + assertThat(executionResults).hasNumberOfSucceededTests(2); + Assertions + .assertThat(TwoTestMethodsWithNewTempDirParameterTestCases.recordedPaths) + .hasSize(2) + .doesNotHaveDuplicates() + .allSatisfy(path -> assertThat(path).doesNotExist()); + } + + } + + static class TwoTestMethodsWithNewTempDirParameterTestCases { + + static List recordedPaths = new CopyOnWriteArrayList<>(); + + @Test + void firstTest(@New(TemporaryDirectory.class) Path tempDir) { + assertThat(tempDir).isEmptyDirectory().startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + + recordedPaths.add(tempDir); + } + + @Test + void secondTest(@New(TemporaryDirectory.class) Path tempDir) { + assertThat(tempDir).isEmptyDirectory().startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + + recordedPaths.add(tempDir); + } + + } + + // --- + + @DisplayName("when a test class has a test method with multiple @New(TemporaryDirectory.class)-annotated parameters") + @Nested + class WhenTestClassHasTestMethodWithMultipleNewTempDirAnnotatedParametersTests { + + @DisplayName("then the parameters are populated with new readable and writeable " + + "temporary directories that are torn down afterwards") + @Test + void thenParametersArePopulatedWithNewReadableAndWriteableTempDirsThatAreTornDownAfterwards() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(SingleTestMethodWithTwoNewTempDirParametersTestCases.class); + assertThat(executionResults).hasSingleSucceededTest(); + Assertions + .assertThat(SingleTestMethodWithTwoNewTempDirParametersTestCases.recordedPaths) + .hasSize(2) + .doesNotHaveDuplicates() + .allSatisfy(path -> assertThat(path).doesNotExist()); + } + + } + + static class SingleTestMethodWithTwoNewTempDirParametersTestCases { + + static List recordedPaths = new CopyOnWriteArrayList<>(); + + @Test + void firstTest(@New(TemporaryDirectory.class) Path firstTempDir, + @New(TemporaryDirectory.class) Path secondTempDir) { + assertThat(firstTempDir).isEmptyDirectory().startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + assertThat(firstTempDir).canReadAndWriteFile(); + assertThat(secondTempDir).isEmptyDirectory().startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + assertThat(secondTempDir).canReadAndWriteFile(); + + recordedPaths.addAll(asList(firstTempDir, secondTempDir)); + } + + } + + // --- + + @DisplayName("when a test class has a constructor with a @New(TemporaryDirectory.class)-annotated parameter") + @Nested + class WhenTestClassHasConstructorWithNewTemporaryDirectoryAnnotatedParameterTests { + + @DisplayName("then each test method has access to a new readable and writeable temporary directory " + + "that lasts as long as the test instance") + @Test + void thenEachTestMethodHasAccessToNewReadableAndWriteableTempDirThatLastsAsLongAsTestInstance() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(TestConstructorWithNewTempDirParameterTestCases.class); + assertThat(executionResults).hasNumberOfSucceededTests(2); + Assertions + .assertThat(TestConstructorWithNewTempDirParameterTestCases.recordedPathsFromConstructor) + .hasSize(2) + .doesNotHaveDuplicates() + .allSatisfy(path -> assertThat(path).doesNotExist()); + } + + } + + static class TestConstructorWithNewTempDirParameterTestCases { + + static List recordedPathsFromConstructor = new CopyOnWriteArrayList<>(); + + private final Path recordedPath; + + TestConstructorWithNewTempDirParameterTestCases(@New(TemporaryDirectory.class) Path tempDir) { + recordedPathsFromConstructor.add(tempDir); + recordedPath = tempDir; + } + + @Test + void firstTest() { + assertThat(recordedPath).isEmptyDirectory().startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + } + + @Test + void secondTest() { + assertThat(recordedPath).isEmptyDirectory().startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + } + + } + + // --- + + @DisplayName("when trying to instantiate a TemporaryDirectory with the wrong number of arguments") + @Nested + class WhenTryingToInstantiateTempDirWithWrongNumberOfArgumentsTests { + + @DisplayName("then an exception mentioning the number of arguments is thrown") + @Test + void thenExceptionMentioningNumberOfArgumentsIsThrown() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(NewTempDirWithWrongNumberOfArgumentsTestCases.class); + executionResults + .allEvents() + .debug() + .assertThatEvents() + .haveExactly(// + 1, // + finished(// + throwable(// + instanceOf(ParameterResolutionException.class), // + message("Unable to create a resource from `" + TemporaryDirectory.class.getTypeName() + + "`"), + cause(instanceOf(IllegalArgumentException.class), + message("Expected 0 or 1 arguments, but got 2"))))); + } + + } + + static class NewTempDirWithWrongNumberOfArgumentsTestCases { + + @Test + void theTest(@New(value = TemporaryDirectory.class, arguments = { "1", "2" }) Path tempDir) { + fail("We should not get this far."); + } + + } + + // --- + + @DisplayName("when a test class has a test method with a " + + "@Shared(factory = TemporaryDirectory.class, name = \"some-name\")-annotated parameter") + @Nested + class WhenTestClassHasTestMethodWithSharedTempDirParameterTests { + + @DisplayName("then the parameter is populated with a new readable and writeable temporary directory " + + "that lasts as long as the test suite") + @Test + void thenParameterIsPopulatedWithNewReadableAndWriteableTempDirThatLastsAsLongAsTheTestSuite() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(SingleTestMethodWithSharedTempDirParameterTestCases.class); + assertThat(executionResults).hasSingleSucceededTest(); + assertThat(SingleTestMethodWithSharedTempDirParameterTestCases.recordedPath).doesNotExist(); + } + + } + + static class SingleTestMethodWithSharedTempDirParameterTestCases { + + static Path recordedPath; + + @Test + void theTest(@Shared(factory = TemporaryDirectory.class, name = "some-name") Path tempDir) { + assertThat(tempDir).isEmptyDirectory().startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + assertThat(tempDir).canReadAndWriteFile(); + + recordedPath = tempDir; + } + + } + + // --- + + @DisplayName("when a test class has a test method with multiple " + + "@Shared(factory = TemporaryDirectory.class, name = \"...\")-annotated parameters with different names") + @Nested + class WhenTestClassHasTestMethodWithMultipleSharedTempDirAnnotatedParametersWithDifferentNamesTests { + + @DisplayName("then the parameters are populated with different readable and writeable " + + "temporary directories that are torn down afterwards") + @Test + void thenParametersArePopulatedWithDifferentReadableAndWriteableTempDirsThatAreTornDownAfterwards() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(SingleTestMethodWithTwoDifferentSharedTempDirParametersTestCases.class); + assertThat(executionResults).hasSingleSucceededTest(); + Assertions + .assertThat(SingleTestMethodWithTwoDifferentSharedTempDirParametersTestCases.recordedPaths) + .hasSize(2) + .doesNotHaveDuplicates() + .allSatisfy(path -> assertThat(path).doesNotExist()); + } + + } + + static class SingleTestMethodWithTwoDifferentSharedTempDirParametersTestCases { + + static List recordedPaths = new CopyOnWriteArrayList<>(); + + @Test + void theTest(@Shared(factory = TemporaryDirectory.class, name = "first-name") Path firstTempDir, + @Shared(factory = TemporaryDirectory.class, name = "second-name") Path secondTempDir) { + assertThat(firstTempDir).isEmptyDirectory().startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + assertThat(firstTempDir).canReadAndWriteFile(); + assertThat(secondTempDir).isEmptyDirectory().startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + assertThat(secondTempDir).canReadAndWriteFile(); + + recordedPaths.add(firstTempDir); + recordedPaths.add(secondTempDir); + } + + } + + // --- + + @DisplayName("when a test class has multiple test methods with a " + + "@Shared(factory = TemporaryDirectory.class, name = \"some-name\")-annotated parameter") + @Nested + class WhenTestClassHasMultipleTestMethodsWithParameterWithSameNamedSharedTempDirTests { + + @DisplayName("then the parameters are populated with a shared readable and writeable " + + "temporary directory that is torn down afterwards") + @Test + void thenParametersArePopulatedWithSharedReadableAndWriteableTempDirThatIsTornDownAfterwards() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(TwoTestMethodsWithSharedSameNameTempDirParameterTestCases.class); + assertThat(executionResults).hasNumberOfSucceededTests(2); + List paths = TwoTestMethodsWithSharedSameNameTempDirParameterTestCases.recordedPaths; + Assertions + .assertThat(paths) + .hasSize(2) + .allSatisfy(path -> Assertions.assertThat(path).isEqualTo(paths.get(0))) + .allSatisfy(path -> Assertions.assertThat(path).doesNotExist()); + } + + } + + static class TwoTestMethodsWithSharedSameNameTempDirParameterTestCases { + + static List recordedPaths = new CopyOnWriteArrayList<>(); + + @Test + void firstTest(@Shared(factory = TemporaryDirectory.class, name = "some-name") Path tempDir) { + assertThat(tempDir).startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + assertThat(tempDir).canReadAndWriteFile(); + + recordedPaths.add(tempDir); + } + + @Test + void secondTest(@Shared(factory = TemporaryDirectory.class, name = "some-name") Path tempDir) { + assertThat(tempDir).startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + assertThat(tempDir).canReadAndWriteFile(); + + recordedPaths.add(tempDir); + } + + } + + // --- + + @DisplayName("when two test classes have a test method with a " + + "@Shared(factory = TemporaryDirectory.class, name = \"some-name\", scope = GLOBAL)-annotated parameter") + @Nested + class WhenTwoTestClassesHaveATestMethodWithParameterWithSameNamedAndGloballyScopedSharedTempDirTests { + + @DisplayName("then the parameters are populated with a shared readable and writeable " + + "temporary directory that is torn down afterwards") + @Test + void thenParametersArePopulatedWithSharedReadableAndWriteableTempDirThatIsTornDownAfterwards() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClasses( // + asList( // + FirstSingleTestMethodWithGlobalTempDirParameterTestCases.class, + SecondSingleTestMethodWithGlobalTempDirParameterTestCases.class)); + assertThat(executionResults).hasNumberOfSucceededTests(2); + assertThat(FirstSingleTestMethodWithGlobalTempDirParameterTestCases.recordedPath) + .isEqualTo(SecondSingleTestMethodWithGlobalTempDirParameterTestCases.recordedPath); + assertThat(FirstSingleTestMethodWithGlobalTempDirParameterTestCases.recordedPath).doesNotExist(); + assertThat(SecondSingleTestMethodWithGlobalTempDirParameterTestCases.recordedPath).doesNotExist(); + } + + } + + static class FirstSingleTestMethodWithGlobalTempDirParameterTestCases { + + static Path recordedPath; + + @Test + void theTest(@Shared(factory = TemporaryDirectory.class, name = "some-name", scope = GLOBAL) Path tempDir) { + assertThat(tempDir).startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + assertThat(tempDir).canReadAndWriteFile(); + + recordedPath = tempDir; + } + + } + + static class SecondSingleTestMethodWithGlobalTempDirParameterTestCases { + + static Path recordedPath; + + @Test + void theTest(@Shared(factory = TemporaryDirectory.class, name = "some-name", scope = GLOBAL) Path tempDir) { + assertThat(tempDir).startsWith(ROOT_TEMP_DIR).isReadable().isWritable(); + assertThat(tempDir).canReadAndWriteFile(); + + recordedPath = tempDir; + } + + } + + // --- + + @DisplayName("when two test classes in the same file have a test method with a " + + "@Shared(factory = TemporaryDirectory.class, name = \"some-name\", scope = SOURCE_FILE)-annotated " + + "parameter") + @Nested + class WhenTwoTestClassesInSameFileHaveTestMethodWithParameterWithSameNamedAndSourceFileScopedSharedTempDirTests { + + @DisplayName("then the parameters are populated with a shared readable and writeable " + + "temporary directory that is torn down afterwards") + @Test + void thenParametersArePopulatedWithSharedReadableAndWriteableTempDirsThatIsTornDownAfterwards() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClass(SingleTestMethodWithSourceFileScopedTempDirParameterTestCases.class); + assertThat(executionResults).hasNumberOfSucceededTests(2); + assertThat(SingleTestMethodWithSourceFileScopedTempDirParameterTestCases.recordedPathForImplicit) + .isEqualTo(SingleTestMethodWithSourceFileScopedTempDirParameterTestCases.recordedPathForExplicit); + assertThat(SingleTestMethodWithSourceFileScopedTempDirParameterTestCases.recordedPathForImplicit) + .doesNotExist(); + assertThat(SingleTestMethodWithSourceFileScopedTempDirParameterTestCases.recordedPathForExplicit) + .doesNotExist(); + } + + } + + static class SingleTestMethodWithSourceFileScopedTempDirParameterTestCases { + + static Path recordedPathForImplicit; + static Path recordedPathForExplicit; + + @Nested + class Implicit { + + @Test + void theTest(@Shared(factory = TemporaryDirectory.class, name = "some-name") Path tempDir) { + recordedPathForImplicit = tempDir; + } + + } + + @Nested + class Explicit { + + @Test + void theTest( + @Shared(factory = TemporaryDirectory.class, name = "some-name", scope = SOURCE_FILE) Path tempDir) { + recordedPathForExplicit = tempDir; + } + + } + + } + + // --- + + @DisplayName("when two test classes in different files have a test method with a " + + "@Shared(factory = TemporaryDirectory.class, name = \"some-name\", scope = SOURCE_FILE)-annotated " + + "parameter") + @Nested + class WhenTwoTestClassesInDiffFilesHaveTestMethodWithParameterWithSameNamedAndSourceFileScopedSharedTempDirTests { + + @DisplayName("then the parameters are populated with unique readable and writeable " + + "temporary directories that that torn down afterwards") + @Test + void thenParametersArePopulatedWithUniqueReadableAndWriteableTempDirsThatAreTornDownAfterwards() { + ExecutionResults executionResults = PioneerTestKit + .executeTestClasses( // + asList( // + FirstSingleTopLevelTestMethodWithSourceFileScopedTempDirParameterTestCases.class, + SecondSingleTopLevelTestMethodWithSourceFileScopedTempDirParameterTestCases.class)); + + assertThat(executionResults).hasNumberOfSucceededTests(2); + + assertThat(FirstSingleTopLevelTestMethodWithSourceFileScopedTempDirParameterTestCases.recordedPath) + .isNotEqualTo( + SecondSingleTopLevelTestMethodWithSourceFileScopedTempDirParameterTestCases.recordedPath); + + assertThat(FirstSingleTopLevelTestMethodWithSourceFileScopedTempDirParameterTestCases.recordedPath) + .doesNotExist(); + assertThat(SecondSingleTopLevelTestMethodWithSourceFileScopedTempDirParameterTestCases.recordedPath) + .doesNotExist(); + } + + } + + static class FirstSingleTopLevelTestMethodWithSourceFileScopedTempDirParameterTestCases { + + static Path recordedPath; + + @Test + void theTest( + @Shared(factory = TemporaryDirectory.class, name = "some-name", scope = SOURCE_FILE) Path tempDir) { + recordedPath = tempDir; + } + + } + + static class SecondSingleTopLevelTestMethodWithSourceFileScopedTempDirParameterTestCases { + + static Path recordedPath; + + @Test + void theTest( + @Shared(factory = TemporaryDirectory.class, name = "some-name", scope = SOURCE_FILE) Path tempDir) { + recordedPath = tempDir; + } + + } + + // --- + + @DisplayName("check that TemporaryDirectory is final") + @Test + void checkThatTemporaryDirectoryIsFinal() { + Assertions.assertThat(TemporaryDirectory.class).isFinal(); + } + +} diff --git a/src/test/java/org/junitpioneer/testkit/ExecutionResults.java b/src/test/java/org/junitpioneer/testkit/ExecutionResults.java index 9b3822808..800e488e9 100644 --- a/src/test/java/org/junitpioneer/testkit/ExecutionResults.java +++ b/src/test/java/org/junitpioneer/testkit/ExecutionResults.java @@ -11,7 +11,9 @@ package org.junitpioneer.testkit; import java.util.List; +import java.util.stream.StreamSupport; +import org.junit.platform.engine.DiscoverySelector; import org.junit.platform.engine.discovery.DiscoverySelectors; import org.junit.platform.testkit.engine.EngineExecutionResults; import org.junit.platform.testkit.engine.EngineTestKit; @@ -29,9 +31,15 @@ public class ExecutionResults { private static final String JUPITER_ENGINE_NAME = "junit-jupiter"; ExecutionResults(Class testClass) { - executionResults = EngineTestKit - .engine(JUPITER_ENGINE_NAME) - .selectors(DiscoverySelectors.selectClass(testClass)) + executionResults = getConfiguredJupiterEngine().selectors(DiscoverySelectors.selectClass(testClass)).execute(); + } + + ExecutionResults(Iterable> testClasses) { + executionResults = getConfiguredJupiterEngine() + .selectors(StreamSupport + .stream(testClasses.spliterator(), false) + .map(DiscoverySelectors::selectClass) + .toArray(DiscoverySelector[]::new)) .execute(); } diff --git a/src/test/java/org/junitpioneer/testkit/PioneerTestKit.java b/src/test/java/org/junitpioneer/testkit/PioneerTestKit.java index 40a5b1efe..2ce407082 100644 --- a/src/test/java/org/junitpioneer/testkit/PioneerTestKit.java +++ b/src/test/java/org/junitpioneer/testkit/PioneerTestKit.java @@ -22,17 +22,27 @@ public class PioneerTestKit { /** * Returns the execution results of the given test class. * - * @param testClass Name of the test class, the results should be returned + * @param testClass The test class instance * @return The execution results */ public static ExecutionResults executeTestClass(Class testClass) { return new ExecutionResults(testClass); } + /** + * Returns the execution results of the given test classes. + * + * @param testClasses The collection of test class instances + * @return The execution results + */ + public static ExecutionResults executeTestClasses(Iterable> testClasses) { + return new ExecutionResults(testClasses); + } + /** * Returns the execution results of the given method of a given test class. * - * @param testClass Name of the test class + * @param testClass The test class instance * @param testMethodName Name of the test method (of the given class) * @return The execution results */ @@ -43,7 +53,7 @@ public static ExecutionResults executeTestMethod(Class testClass, String test /** * Returns the execution results of the given method of a given test class. * - * @param testClass Name of the test class + * @param testClass The test class instance * @param testMethodName Name of the test method (of the given class) * @param methodParameterTypes Class type(s) of the parameter(s) * @return The execution results diff --git a/src/test/java/org/junitpioneer/testkit/PioneerTestKitTests.java b/src/test/java/org/junitpioneer/testkit/PioneerTestKitTests.java index 98a463343..b82891496 100644 --- a/src/test/java/org/junitpioneer/testkit/PioneerTestKitTests.java +++ b/src/test/java/org/junitpioneer/testkit/PioneerTestKitTests.java @@ -10,6 +10,7 @@ package org.junitpioneer.testkit; +import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junitpioneer.testkit.assertion.PioneerAssert.assertThat; @@ -30,6 +31,14 @@ void executeTestClass() { assertThat(results).hasNumberOfStartedTests(1); } + @Test + @DisplayName("all tests of all given classes") + void executeTestClasses() { + ExecutionResults results = PioneerTestKit.executeTestClasses(asList(DummyClass.class, SecondDummyClass.class)); + + assertThat(results).hasNumberOfStartedTests(2); + } + @Test @DisplayName("a specific method") void executeTestMethod() { @@ -100,4 +109,13 @@ void nothing() { } + static class SecondDummyClass { + + @Test + void nothing() { + // Do nothing + } + + } + } diff --git a/src/test/java/org/junitpioneer/testkit/assertion/PioneerAssert.java b/src/test/java/org/junitpioneer/testkit/assertion/PioneerAssert.java index c1496e905..7336f2f41 100644 --- a/src/test/java/org/junitpioneer/testkit/assertion/PioneerAssert.java +++ b/src/test/java/org/junitpioneer/testkit/assertion/PioneerAssert.java @@ -10,327 +10,25 @@ package org.junitpioneer.testkit.assertion; -import static java.util.stream.Collectors.toList; +import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import java.util.stream.Stream; - -import org.assertj.core.api.AbstractAssert; -import org.assertj.core.api.Assertions; -import org.assertj.core.api.ListAssert; -import org.junit.platform.engine.TestExecutionResult; -import org.junit.platform.engine.reporting.ReportEntry; -import org.junit.platform.testkit.engine.Events; import org.junitpioneer.testkit.ExecutionResults; -import org.junitpioneer.testkit.assertion.reportentry.ReportEntryContentAssert; -import org.junitpioneer.testkit.assertion.single.TestCaseFailureAssert; -import org.junitpioneer.testkit.assertion.single.TestCaseStartedAssert; -import org.junitpioneer.testkit.assertion.suite.TestSuiteAssert; -import org.junitpioneer.testkit.assertion.suite.TestSuiteContainersAssert; -import org.junitpioneer.testkit.assertion.suite.TestSuiteTestsAssert; - -public class PioneerAssert extends AbstractAssert - implements ExecutionResultAssert, TestSuiteAssert, TestSuiteTestsAssert.TestSuiteTestsFailureAssert, - TestSuiteContainersAssert.TestSuiteContainersFailureAssert { - - private boolean test = true; - - public static ExecutionResultAssert assertThat(ExecutionResults actual) { - return new PioneerAssert(actual); - } - - private PioneerAssert(ExecutionResults actual) { - super(actual, PioneerAssert.class); - } - - @Override - public ReportEntryContentAssert hasNumberOfReportEntries(int expected) { - try { - List> entries = reportEntries(); - Assertions.assertThat(entries).hasSize(expected); - Integer[] ones = IntStream.generate(() -> 1).limit(expected).boxed().toArray(Integer[]::new); - Assertions.assertThat(entries).extracting(Map::size).containsExactly(ones); - - List> entryList = entries - .stream() - .flatMap(map -> map.entrySet().stream()) - .collect(Collectors.toList()); - - return new ReportEntryAssertBase(entryList, expected); - } - catch (AssertionError error) { - getAllExceptions(actual.allEvents().reportingEntryPublished()).forEach(error::addSuppressed); - throw error; - } - } - - @Override - public ReportEntryContentAssert hasSingleReportEntry() { - return hasNumberOfReportEntries(1); - } - - @Override - public void hasNoReportEntries() { - try { - Assertions.assertThat(reportEntries()).isEmpty(); - } - catch (AssertionError error) { - getAllExceptions(actual.allEvents()).forEach(error::addSuppressed); - throw error; - } - } - - @Override - public TestCaseStartedAssert hasSingleStartedTest() { - Events events = actual.testEvents(); - assertSingleTest(events.started()); - // Don't filter started() here; this would prevent further assertions on test outcome on - // returned assert because outcome is reported for the FINISHED event, not the STARTED one - return new TestCaseAssertBase(events); - } - - @Override - public TestCaseFailureAssert hasSingleFailedTest() { - return assertSingleTest(actual.testEvents().failed()); - } - - @Override - public void hasSingleAbortedTest() { - assertSingleTest(actual.testEvents().aborted()); - } - - @Override - public void hasSingleSucceededTest() { - assertSingleTest(actual.testEvents().succeeded()); - } - - @Override - public void hasSingleSkippedTest() { - assertSingleTest(actual.testEvents().skipped()); - } - - @Override - public TestCaseStartedAssert hasSingleDynamicallyRegisteredTest() { - Events events = actual.testEvents(); - assertSingleTest(events.dynamicallyRegistered()); - // Don't filter dynamicallyRegistered() here; this would prevent further assertions on test outcome on - // returned assert because outcome is reported for the FINISHED event, not the DYNAMIC_TEST_REGISTERED one - return new TestCaseAssertBase(events); - } - - private TestCaseAssertBase assertSingleTest(Events events) { - try { - Assertions.assertThat(events.count()).isEqualTo(1); - } - catch (AssertionError error) { - getAllExceptions(actual.allEvents()).forEach(error::addSuppressed); - throw error; - } - return new TestCaseAssertBase(events); - } - - @Override - public TestCaseStartedAssert hasSingleStartedContainer() { - Events events = actual.containerEvents(); - assertSingleTest(events.started()); - // Don't filter started() here; this would prevent further assertions on container outcome on - // returned assert because outcome is reported for the FINISHED event, not the STARTED one - return new TestCaseAssertBase(events); - } - - @Override - public TestCaseFailureAssert hasSingleFailedContainer() { - return assertSingleContainer(actual.containerEvents().failed()); - } - - @Override - public void hasSingleAbortedContainer() { - assertSingleContainer(actual.containerEvents().aborted()); - } - - @Override - public void hasSingleSucceededContainer() { - assertSingleContainer(actual.containerEvents().succeeded()); - } - - @Override - public void hasSingleSkippedContainer() { - assertSingleContainer(actual.containerEvents().skipped()); - } - - @Override - public TestCaseStartedAssert hasSingleDynamicallyRegisteredContainer() { - Events events = actual.containerEvents(); - assertSingleContainer(events.dynamicallyRegistered()); - // Don't filter dynamicallyRegistered() here; this would prevent further assertions on container outcome on - // returned assert because outcome is reported for the FINISHED event, not the DYNAMIC_TEST_REGISTERED one - return new TestCaseAssertBase(events); - } - private TestCaseAssertBase assertSingleContainer(Events events) { - try { - Assertions.assertThat(events.count()).isEqualTo(1); - } - catch (AssertionError error) { - getAllExceptions(actual.allEvents()).forEach(error::addSuppressed); - throw error; - } - return new TestCaseAssertBase(events); - } - - @Override - public TestSuiteTestsAssert hasNumberOfStartedTests(int expected) { - return hasNumberOfSpecificTests(actual.testEvents().started().count(), expected); - } - - @Override - public TestSuiteTestsFailureAssert hasNumberOfFailedTests(int expected) { - this.test = true; - return hasNumberOfSpecificTests(actual.testEvents().failed().count(), expected); - } - - @Override - public TestSuiteTestsAssert hasNumberOfAbortedTests(int expected) { - return hasNumberOfSpecificTests(actual.testEvents().aborted().count(), expected); - } - - @Override - public TestSuiteTestsAssert hasNumberOfSucceededTests(int expected) { - return hasNumberOfSpecificTests(actual.testEvents().succeeded().count(), expected); - } - - @Override - public TestSuiteTestsAssert hasNumberOfSkippedTests(int expected) { - return hasNumberOfSpecificTests(actual.testEvents().skipped().count(), expected); - } - - @Override - public TestSuiteTestsAssert hasNumberOfDynamicallyRegisteredTests(int expected) { - return hasNumberOfSpecificTests(actual.testEvents().dynamicallyRegistered().count(), expected); - } - - private TestSuiteTestsFailureAssert hasNumberOfSpecificTests(long tests, int expected) { - try { - Assertions.assertThat(tests).isEqualTo(expected); - return this; - } - catch (AssertionError error) { - getAllExceptions(actual.allEvents()).forEach(error::addSuppressed); - throw error; - } - } - - @Override - public TestSuiteContainersAssert hasNumberOfStartedContainers(int expected) { - return hasNumberOfSpecificContainers(actual.containerEvents().started().count(), expected); - } - - @Override - public TestSuiteContainersFailureAssert hasNumberOfFailedContainers(int expected) { - this.test = false; - return hasNumberOfSpecificContainers(actual.containerEvents().failed().count(), expected); - } - - @Override - public TestSuiteContainersAssert hasNumberOfAbortedContainers(int expected) { - return hasNumberOfSpecificContainers(actual.containerEvents().aborted().count(), expected); - } - - @Override - public TestSuiteContainersAssert hasNumberOfSucceededContainers(int expected) { - return hasNumberOfSpecificContainers(actual.containerEvents().succeeded().count(), expected); - } - - @Override - public TestSuiteContainersAssert hasNumberOfSkippedContainers(int expected) { - return hasNumberOfSpecificContainers(actual.containerEvents().skipped().count(), expected); - } - - @Override - public TestSuiteContainersAssert hasNumberOfDynamicallyRegisteredContainers(int expected) { - return hasNumberOfSpecificContainers(actual.containerEvents().dynamicallyRegistered().count(), expected); - } - - private TestSuiteContainersFailureAssert hasNumberOfSpecificContainers(long containers, int expected) { - try { - Assertions.assertThat(containers).isEqualTo(expected); - return this; - } - catch (AssertionError error) { - getAllExceptions(actual.allEvents()).forEach(error::addSuppressed); - throw error; - } - } - - private List> reportEntries() { - return actual - .allEvents() - .reportingEntryPublished() - .stream() - .map(event -> event.getPayload(ReportEntry.class)) - .filter(Optional::isPresent) - .map(Optional::get) - .map(ReportEntry::getKeyValuePairs) - .collect(toList()); - } - - @Override - public final ListAssert withExceptionInstancesOf(Class exceptionType) { - return assertExceptions(events -> { - Stream> classStream = getAllExceptions(events).map(Throwable::getClass); - Assertions.assertThat(classStream).containsOnly(exceptionType); - }); - } - - @Override - public ListAssert withExceptions() { - return assertExceptions( - events -> Assertions.assertThat(events.failed().count()).isEqualTo(getAllExceptions(events).count())); - } - - private ListAssert assertExceptions(Consumer assertion) { - try { - Events events = getProperEvents(); - assertion.accept(events); - return new ListAssert<>(getAllExceptions(events).map(Throwable::getMessage)); - } - catch (AssertionError error) { - getAllExceptions(actual.allEvents()).forEach(error::addSuppressed); - throw error; - } - } - - private Events getProperEvents() { - return this.test ? actual.testEvents() : actual.containerEvents(); - } +/** + * Entry point to all JUnit Pioneer assertions. + */ +public class PioneerAssert { - static Stream getAllExceptions(Events events) { - return events - .stream() - .map(fail -> fail.getPayload(TestExecutionResult.class)) - .filter(Optional::isPresent) - .map(Optional::get) - .map(TestExecutionResult::getThrowable) - .filter(Optional::isPresent) - .map(Optional::get); + private PioneerAssert() { + // private constructor to prevent instantiation } - @Override - public void assertingExceptions(Predicate> predicate) { - List thrownExceptions = getAllExceptions(getProperEvents()).collect(toList()); - Assertions.assertThat(predicate).accepts(thrownExceptions); + public static ExecutionResultAssert assertThat(ExecutionResults actual) { + return new PioneerExecutionResultAssert(actual); } - @Override - public void andThenCheckExceptions(Consumer> testFunction) { - List thrownExceptions = getAllExceptions(getProperEvents()).collect(toList()); - testFunction.accept(thrownExceptions); + public static PioneerPathAssert assertThat(Path actual) { + return new PioneerPathAssert(actual); } } diff --git a/src/test/java/org/junitpioneer/testkit/assertion/PioneerExecutionResultAssert.java b/src/test/java/org/junitpioneer/testkit/assertion/PioneerExecutionResultAssert.java new file mode 100644 index 000000000..a2b75fa0f --- /dev/null +++ b/src/test/java/org/junitpioneer/testkit/assertion/PioneerExecutionResultAssert.java @@ -0,0 +1,332 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.testkit.assertion; + +import static java.util.stream.Collectors.toList; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.ListAssert; +import org.junit.platform.engine.TestExecutionResult; +import org.junit.platform.engine.reporting.ReportEntry; +import org.junit.platform.testkit.engine.Events; +import org.junitpioneer.testkit.ExecutionResults; +import org.junitpioneer.testkit.assertion.reportentry.ReportEntryContentAssert; +import org.junitpioneer.testkit.assertion.single.TestCaseFailureAssert; +import org.junitpioneer.testkit.assertion.single.TestCaseStartedAssert; +import org.junitpioneer.testkit.assertion.suite.TestSuiteAssert; +import org.junitpioneer.testkit.assertion.suite.TestSuiteContainersAssert; +import org.junitpioneer.testkit.assertion.suite.TestSuiteTestsAssert; + +class PioneerExecutionResultAssert extends AbstractAssert + implements ExecutionResultAssert, TestSuiteAssert, TestSuiteTestsAssert.TestSuiteTestsFailureAssert, + TestSuiteContainersAssert.TestSuiteContainersFailureAssert { + + private boolean test = true; + + PioneerExecutionResultAssert(ExecutionResults actual) { + super(actual, PioneerExecutionResultAssert.class); + } + + @Override + public ReportEntryContentAssert hasNumberOfReportEntries(int expected) { + try { + List> entries = reportEntries(); + Assertions.assertThat(entries).hasSize(expected); + Integer[] ones = IntStream.generate(() -> 1).limit(expected).boxed().toArray(Integer[]::new); + Assertions.assertThat(entries).extracting(Map::size).containsExactly(ones); + + List> entryList = entries + .stream() + .flatMap(map -> map.entrySet().stream()) + .collect(Collectors.toList()); + + return new ReportEntryAssertBase(entryList, expected); + } + catch (AssertionError error) { + getAllExceptions(actual.allEvents().reportingEntryPublished()).forEach(error::addSuppressed); + throw error; + } + } + + @Override + public ReportEntryContentAssert hasSingleReportEntry() { + return hasNumberOfReportEntries(1); + } + + @Override + public void hasNoReportEntries() { + try { + Assertions.assertThat(reportEntries()).isEmpty(); + } + catch (AssertionError error) { + getAllExceptions(actual.allEvents()).forEach(error::addSuppressed); + throw error; + } + } + + @Override + public TestCaseStartedAssert hasSingleStartedTest() { + Events events = actual.testEvents(); + assertSingleTest(events.started()); + // Don't filter started() here; this would prevent further assertions on test outcome on + // returned assert because outcome is reported for the FINISHED event, not the STARTED one + return new TestCaseAssertBase(events); + } + + @Override + public TestCaseFailureAssert hasSingleFailedTest() { + return assertSingleTest(actual.testEvents().failed()); + } + + @Override + public void hasSingleAbortedTest() { + assertSingleTest(actual.testEvents().aborted()); + } + + @Override + public void hasSingleSucceededTest() { + assertSingleTest(actual.testEvents().succeeded()); + } + + @Override + public void hasSingleSkippedTest() { + assertSingleTest(actual.testEvents().skipped()); + } + + @Override + public TestCaseStartedAssert hasSingleDynamicallyRegisteredTest() { + Events events = actual.testEvents(); + assertSingleTest(events.dynamicallyRegistered()); + // Don't filter dynamicallyRegistered() here; this would prevent further assertions on test outcome on + // returned assert because outcome is reported for the FINISHED event, not the DYNAMIC_TEST_REGISTERED one + return new TestCaseAssertBase(events); + } + + private TestCaseAssertBase assertSingleTest(Events events) { + try { + Assertions.assertThat(events.count()).isEqualTo(1); + } + catch (AssertionError error) { + getAllExceptions(actual.allEvents()).forEach(error::addSuppressed); + throw error; + } + return new TestCaseAssertBase(events); + } + + @Override + public TestCaseStartedAssert hasSingleStartedContainer() { + Events events = actual.containerEvents(); + assertSingleTest(events.started()); + // Don't filter started() here; this would prevent further assertions on container outcome on + // returned assert because outcome is reported for the FINISHED event, not the STARTED one + return new TestCaseAssertBase(events); + } + + @Override + public TestCaseFailureAssert hasSingleFailedContainer() { + return assertSingleContainer(actual.containerEvents().failed()); + } + + @Override + public void hasSingleAbortedContainer() { + assertSingleContainer(actual.containerEvents().aborted()); + } + + @Override + public void hasSingleSucceededContainer() { + assertSingleContainer(actual.containerEvents().succeeded()); + } + + @Override + public void hasSingleSkippedContainer() { + assertSingleContainer(actual.containerEvents().skipped()); + } + + @Override + public TestCaseStartedAssert hasSingleDynamicallyRegisteredContainer() { + Events events = actual.containerEvents(); + assertSingleContainer(events.dynamicallyRegistered()); + // Don't filter dynamicallyRegistered() here; this would prevent further assertions on container outcome on + // returned assert because outcome is reported for the FINISHED event, not the DYNAMIC_TEST_REGISTERED one + return new TestCaseAssertBase(events); + } + + private TestCaseAssertBase assertSingleContainer(Events events) { + try { + Assertions.assertThat(events.count()).isEqualTo(1); + } + catch (AssertionError error) { + getAllExceptions(actual.allEvents()).forEach(error::addSuppressed); + throw error; + } + return new TestCaseAssertBase(events); + } + + @Override + public TestSuiteTestsAssert hasNumberOfStartedTests(int expected) { + return hasNumberOfSpecificTests(actual.testEvents().started().count(), expected); + } + + @Override + public TestSuiteTestsFailureAssert hasNumberOfFailedTests(int expected) { + this.test = true; + return hasNumberOfSpecificTests(actual.testEvents().failed().count(), expected); + } + + @Override + public TestSuiteTestsAssert hasNumberOfAbortedTests(int expected) { + return hasNumberOfSpecificTests(actual.testEvents().aborted().count(), expected); + } + + @Override + public TestSuiteTestsAssert hasNumberOfSucceededTests(int expected) { + return hasNumberOfSpecificTests(actual.testEvents().succeeded().count(), expected); + } + + @Override + public TestSuiteTestsAssert hasNumberOfSkippedTests(int expected) { + return hasNumberOfSpecificTests(actual.testEvents().skipped().count(), expected); + } + + @Override + public TestSuiteTestsAssert hasNumberOfDynamicallyRegisteredTests(int expected) { + return hasNumberOfSpecificTests(actual.testEvents().dynamicallyRegistered().count(), expected); + } + + private TestSuiteTestsFailureAssert hasNumberOfSpecificTests(long tests, int expected) { + try { + Assertions.assertThat(tests).isEqualTo(expected); + return this; + } + catch (AssertionError error) { + getAllExceptions(actual.allEvents()).forEach(error::addSuppressed); + throw error; + } + } + + @Override + public TestSuiteContainersAssert hasNumberOfStartedContainers(int expected) { + return hasNumberOfSpecificContainers(actual.containerEvents().started().count(), expected); + } + + @Override + public TestSuiteContainersFailureAssert hasNumberOfFailedContainers(int expected) { + this.test = false; + return hasNumberOfSpecificContainers(actual.containerEvents().failed().count(), expected); + } + + @Override + public TestSuiteContainersAssert hasNumberOfAbortedContainers(int expected) { + return hasNumberOfSpecificContainers(actual.containerEvents().aborted().count(), expected); + } + + @Override + public TestSuiteContainersAssert hasNumberOfSucceededContainers(int expected) { + return hasNumberOfSpecificContainers(actual.containerEvents().succeeded().count(), expected); + } + + @Override + public TestSuiteContainersAssert hasNumberOfSkippedContainers(int expected) { + return hasNumberOfSpecificContainers(actual.containerEvents().skipped().count(), expected); + } + + @Override + public TestSuiteContainersAssert hasNumberOfDynamicallyRegisteredContainers(int expected) { + return hasNumberOfSpecificContainers(actual.containerEvents().dynamicallyRegistered().count(), expected); + } + + private TestSuiteContainersFailureAssert hasNumberOfSpecificContainers(long containers, int expected) { + try { + Assertions.assertThat(containers).isEqualTo(expected); + return this; + } + catch (AssertionError error) { + getAllExceptions(actual.allEvents()).forEach(error::addSuppressed); + throw error; + } + } + + private List> reportEntries() { + return actual + .allEvents() + .reportingEntryPublished() + .stream() + .map(event -> event.getPayload(ReportEntry.class)) + .filter(Optional::isPresent) + .map(Optional::get) + .map(ReportEntry::getKeyValuePairs) + .collect(toList()); + } + + @Override + public final ListAssert withExceptionInstancesOf(Class exceptionType) { + return assertExceptions(events -> { + Stream> classStream = getAllExceptions(events).map(Throwable::getClass); + Assertions.assertThat(classStream).containsOnly(exceptionType); + }); + } + + @Override + public ListAssert withExceptions() { + return assertExceptions( + events -> Assertions.assertThat(events.failed().count()).isEqualTo(getAllExceptions(events).count())); + } + + private ListAssert assertExceptions(Consumer assertion) { + try { + Events events = getProperEvents(); + assertion.accept(events); + return new ListAssert<>(getAllExceptions(events).map(Throwable::getMessage)); + } + catch (AssertionError error) { + getAllExceptions(actual.allEvents()).forEach(error::addSuppressed); + throw error; + } + } + + private Events getProperEvents() { + return this.test ? actual.testEvents() : actual.containerEvents(); + } + + static Stream getAllExceptions(Events events) { + return events + .stream() + .map(fail -> fail.getPayload(TestExecutionResult.class)) + .filter(Optional::isPresent) + .map(Optional::get) + .map(TestExecutionResult::getThrowable) + .filter(Optional::isPresent) + .map(Optional::get); + } + + @Override + public void assertingExceptions(Predicate> predicate) { + List thrownExceptions = getAllExceptions(getProperEvents()).collect(toList()); + Assertions.assertThat(predicate).accepts(thrownExceptions); + } + + @Override + public void andThenCheckExceptions(Consumer> testFunction) { + List thrownExceptions = getAllExceptions(getProperEvents()).collect(toList()); + testFunction.accept(thrownExceptions); + } + +} diff --git a/src/test/java/org/junitpioneer/testkit/assertion/PioneerPathAssert.java b/src/test/java/org/junitpioneer/testkit/assertion/PioneerPathAssert.java new file mode 100644 index 000000000..3f2674378 --- /dev/null +++ b/src/test/java/org/junitpioneer/testkit/assertion/PioneerPathAssert.java @@ -0,0 +1,64 @@ +/* + * Copyright 2016-2022 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.testkit.assertion; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.singletonList; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +import org.assertj.core.api.PathAssert; + +public class PioneerPathAssert extends PathAssert { + + PioneerPathAssert(Path path) { + super(path); + } + + public PioneerPathAssert canReadAndWriteFile() { + isNotNull(); + + Path textFile; + try { + textFile = Files.createTempFile(actual, "some-text-file", ".txt"); + } + catch (IOException e1) { + throw failure("Cannot create a file"); + } + + String expectedText = "some-text"; + try { + Files.write(textFile, singletonList(expectedText)); + } + catch (IOException e) { + throw failure("Cannot write to a file"); + } + + String actualText; + try { + actualText = new String(Files.readAllBytes(textFile), UTF_8).trim(); + } + catch (IOException e) { + throw failure("Cannot read from a file"); + } + + if (!Objects.equals(actualText, expectedText)) { + throw failureWithActualExpected(actualText, expectedText, "File expected to contain <%s>, but was <%s>", + expectedText, actualText); + } + + return this; + } + +} diff --git a/src/test/module/module-info.java b/src/test/module/module-info.java index b5213784f..94c36560e 100644 --- a/src/test/module/module-info.java +++ b/src/test/module/module-info.java @@ -29,6 +29,7 @@ opens org.junitpioneer.jupiter.cartesian to org.junit.platform.commons; opens org.junitpioneer.jupiter.issue to org.junit.platform.commons; opens org.junitpioneer.jupiter.params to org.junit.platform.commons; + opens org.junitpioneer.jupiter.resource to org.junit.platform.commons; opens org.junitpioneer.jupiter.json to org.junit.platform.commons, com.fasterxml.jackson.databind; provides org.junit.platform.launcher.TestExecutionListener @@ -41,6 +42,7 @@ requires org.mockito; requires org.assertj.core; requires nl.jqno.equalsverifier; + requires com.google.jimfs; // via org.assertj.core requires java.instrument;