Internals Of JUnit : Deep Diving Into `@ParameterizedTest` Annotation
Understanding internals of @ParameterizedTest Annotation
We have started a new “Internals of” series where the goal is to deep dive into small pieces of functionality of different Java and its ecosystem of frameworks and learn how it’s been designed. We review the code and learn through the lens of design patterns and their implementation.
Table of Contents
Overview
Deep Dive
Execution Flow
Internals
Learnings
Conclusion
Overview
The
@ParameterizedTest
annotation is used to indicate that a method is a parameterized test, which means the test method can be run multiple times with different inputs.We annotate a test method with
@ParameterizedTest
and provide a source of arguments (like@ValueSource
,@MethodSource
,@CsvSource
, etc.).The parameters for the test can come from various sources:
@ValueSource
: Supplies a single array of literals (e.g.,int
,String
,double
).@EnumSource
: Supplies values from an enumeration.@MethodSource
: References a method that provides the arguments.@CsvSource
: Supplies arguments from a CSV-formatted string.@ArgumentsSource
: Allows the use of custom argument providers.
Example:
Deep Diving into @ParameterizedTest
Execution Flow
When JUnit encounters a
@ParameterizedTest
, it prepares the test by identifying the parameter source and generating a set of test invocations, each with a different set of parameters.JUnit injects each set of parameters into the test method. The framework uses
ParameterResolver
to resolve the arguments based on the source annotation.The test method is then invoked multiple times, once for each set of parameters. If the method throws an exception, the test fails for that specific set of parameters, but the other test cases still run.
Internals
Class Diagram
TestTemplateInvocationContextProvider interface:
TestTemplateInvocationContextProvider
is an interface in JUnit 5 that is used to provide TestTemplateInvocationContext
instances for test methods annotated with @TestTemplate
. This interface plays a critical role in the test execution framework by allowing the customization of test method execution, including parameterized tests.
supportsTestTemplate(ExtensionContext context)
: This method checks if the provider supports the@TestTemplate
annotated method in the given context. It allows the extension to decide whether it can handle the test method.provideTestTemplateInvocationContexts(ExtensionContext context)
: This method returns a stream ofTestTemplateInvocationContext
instances. EachTestTemplateInvocationContext
represents a set of parameters and configurations for a single invocation of the test method.
How does it get used?
The JUnit engine, specifically the Jupiter engine, uses
TestTemplateInvocationContextProvider
to provide contexts for test methods marked with@TestTemplate
. The engine calls these methods to obtain the contexts for executing the test method multiple times.Implementations of
TestTemplateInvocationContextProvider
are typically used as extensions in JUnit. For example,ParameterizedTestExtension
implements this interface to handle parameterized tests.
ParameterizedTestExtension class: (Link)
The ParameterizedTestExtension
class in JUnit 5, found in the JUnit Jupiter Params module, is a key component for handling parameterized tests. It implements the TestTemplateInvocationContextProvider
interface, which is used to provide invocation contexts for parameterized tests.
Core Responsibilities
Supplies a stream of
TestTemplateInvocationContext
instances, each representing a unique set of parameters for the test method.Manages different sources of parameters, such as CSV files, method sources, enums, etc.
Ensures that the provided parameters meet the required constraints.
Overriden Methods:
`supportsTestTemplate(ExtensionContext context)`:
Determines if the
ParameterizedTestExtension
can handle a given test method.Steps:
Check if the test method is present.
Verifies that the method is annotated with
@ParameterizedTest
.Creates a context for the method and validates its signature.
Stores the context in the
ExtensionContext
.Returns
true
if all checks pass.
This method ensures that only valid parameterized test methods are processed by the
ParameterizedTestExtension
, allowing it to handle parameterized tests correctly.`provideTestTemplateInvocationContexts(ExtensionContext extensionContext)`;
provideTestTemplateInvocationContexts
: This method is responsible for providing a stream ofTestTemplateInvocationContext
instances for parameterized tests.Steps:
Retrieve the test method and context.
Configure the name formatter for test invocations.
Process argument providers and retrieve arguments.
Create invocation contexts for each set of arguments.
Ensure that at least one set of arguments is provided.
This method enables the parameterized test extension to handle various sources of test parameters and manage the execution of parameterized tests effectively.
TestTemplateInvocationContext interface:
The
TestTemplateInvocationContext
interface in JUnit 5 is used to define the context for executing test templates, which are special forms of tests that can be executed multiple times with different parameters.A class implementing
TestTemplateInvocationContext
provides the context needed for each invocation of a test template. This includes custom display names and additional extensions specific to each invocation.
ParameterizedTestInvocationContext class: (Link)
This class provides context for each invocation of a parameterized test. This includes details about the arguments being passed to the test method and other configurations specific to parameterized tests.
It is used internally by the JUnit 5 framework to execute parameterized tests with different sets of parameters.
While
ParameterizedTestInvocationContext
is a specific implementation ofTestTemplateInvocationContext
and often managed internally by JUnit 5, a typical use case involves it being used in the context of test template execution.
Overriden Methods:
ParameterizedTestInvocationContext(..){}
ParameterizedTestNameFormatter formatter
:Responsible for formatting the display name of the test based on the parameters and other context information.
Used to create a meaningful name for each test invocation, which can be helpful for understanding test reports and logs.
ParameterizedTestMethodContext methodContext
:Contains metadata about the parameterized test method, such as its signature and configuration.
Provides context about how the method should be invoked, including details about parameters and their expected resolutions.
int invocationIndex
:Indicates the index of the current test invocation.
Used to differentiate between multiple invocations of the parameterized test and helps in generating unique display names and managing the test execution sequence.
consumedArguments
:A method or mechanism to process or consume the arguments based on the method context. This is not a parameter but a derived field.
Determines how the arguments are used or processed in the context of the test.
getDisplayName(int invocationIndex)
Returns a custom display name for the test invocation. This name is used in test reports and outputs.
Parameter
invocationIndex
- The index of the current test invocation.Returns: A
String
that serves as the display name for the test case.
getAdditionalExtensions()
:To return a list of additional JUnit extensions that should be applied to the test method.
Learnings
There are several key learnings we can take away from the internal implementation of the `@ParameterizedTest` annotation in JUnit 5:
1: Separation Of Concern
One of the key things we can learn from internals is how the logic follows the separation of concerns principle. The implementation effectively separates the responsibility of managing different parameter sources, generating test invocations, and executing tests.
When designing complex systems, breaking down functionality into modular components (e.g., ParameterizedTestExtension
, TestTemplateInvocationContextProvider
, TestTemplateInvocationContext
) can help manage complexity and make the system easier to extend and maintain.
2: Extensibility
The @ParameterizedTest
architecture shows how to design systems that are easily extensible. The ability to plug in different argument providers (via annotations) or even create custom ones (@ArgumentsSource
) is a powerful example of how to design APIs and frameworks that developers can extend according to their needs.
3: Code Reusability
The concept of parameterized tests encourages code reusability. Instead of writing multiple test cases for different input values, we write a single test method that can be run with various inputs. This reduces code duplication and makes the tests easier to manage and update
4: Validation and Error Handling
The ParameterizedTestExtension
performs various checks, such as validating method signatures and ensuring that parameters are supplied correctly. This highlights the importance of early validation and robust error handling in the code to prevent issues during runtime.
5: Reporting and Diagnostics
The way @ParameterizedTest
handles the naming and reporting of test cases, especially with the ParameterizedTestNameFormatter
, teaches the importance of providing clear and understandable diagnostics.
When writing tests or other critical code, we should consider how outputs will be presented to users or developers. Clear test names, logs, and error messages are crucial for quickly diagnosing and fixing issues.
6: Declarative vs. Imperative
The use of annotations like @ParameterizedTest
exemplifies a declarative approach to programming, where we describe what should happen rather than how it should happen. This can lead to more readable and maintainable code.
Conclusion
@ParameterizedTest is a powerful example of how to design flexible, reusable, and extensible systems.
It encourages best practices in software design, including clear separation of concerns, effective use of design patterns, and attention to user experience through clear error handling and reporting.
By applying these principles, we can create more maintainable, adaptable, and robust code.