Introduction
Over the years I have written and collected various approaches to unit testing challenging code. This library is my attempt to formalize these tools into a cohesive codebase and provide them for others to use.
Test Things? Is it a statement, as in "things for testing", or is it a command, telling you to "go forth and test"? It’s up to you.
Warning
|
This is project is considered a BETA release at this point. I don’t foresee any breaking changes at this point, but keep in mind that until a 1.0.0 release things are subject to change. |
Getting Started
The Test-Things artifacts are available via the Maven Central repository. Below are the dependency coordinates for Gradle and Maven.
For Gradle:
testImplementation 'io.github.cjstehno:test-things:0.1.0'
For Maven:
<dependency> <groupId>io.github.cjstehno</groupId> <artifactId>test-things</artifactId> <version>0.1.0</version> <scope>test</scope> </dependency>
Data Fixtures
In testing, I often find myself just needing a handful of values for test fixtures. The test-things library provides a handful of useful sources for "grouped" fixture values:
Color Names. The ColorName
enum
has various color names.
Female Names. The FelameName
enum
contains the most common female names, per some list I found on the internet.
Male Names. The MaleName
enum
contains the most common male names, per some list I found online.
Unisex Names. The UnisexName
enum
contains the most common unisex names, again, found on some web site.
Birth Genders. The BirthGenders
enum
of the birth genders.
Phonetic Alphabet. The PhoneticAlphabet
enum
has the military phonetic alphabet - one of my go-tos.
Planets. The Planet
enum
has the names of all nine planets… yes, Pluto I still love you.
US States. The UsState
enum
has the names of the fifty United States of America.
Person. The Person
class is a generic simple person object that is also serializable.
JUnit 5 Extensions
An interesting, and useful part of JUnit 5 is the Extension Mechanism. You can quickly and easily add extensions to make your testing easier and more robust.
The test-things library provides a handful of extensions to aid in test simplification.
-
The
LifecycleExtension
provides a means of getting some callbacks in before the extensions run. -
The
DatabaseExtension
provides aDataSource
-based testing environment. -
The
SharedRandomExtension
provides a means of pinning theSharedRandom
instances for repeatable testing. -
The
ResourcesExtension
provides helpers for loading and working with classpath resources in tests. -
The
LogAppenderExtension
provides a means of capturing logging events for test inspection.
LifecycleExtension
The LifecycleExtension
is an extension used to an occasional gap in the JUnit extension framework - specifically around how the before and after callbacks are applied.
Consider the case when you have an extension configured in a test as follows:
@ExtendWith(HelpfulExtension.class)
class SomeInterestingTest {
private DataProvider provider;
@BeforeEach void beforeEach(){
provider = configureProvider();
provider.start();
}
@AfterEach void afterEach(){
provider.shutdown();
}
// Tests that use the provider with the extension
}
Here we have a (fictional) DataProvider
that must be configured in a specific manner for the test; however, this DataProvider
is also discovered by the HelpfulExtension
. The problem is that the @BeforeEach
and @AfterEach
annotated methods are executed after those provided by the extension, meaning that depending on how the extension is written, you may not have the provider
populated when you need it - if it’s your extension you can modify the extension to resolve this issue, but if the extension is from a 3rd party you need to find another approach.
That’s where the LifecycleExtension
comes into play. If you add it before the HelpfulExtension
in the list of extensions and then modify the two configuration methods as follows:
@ExtendWith({LifecyleExtension.class, HelpfulExtension.class})
class SomeInterestingTest {
private DataProvider provider;
@Lifecycle(BEFORE) void beforeEach(){
provider = configureProvider();
provider.start();
}
@Lifecycle(AFTER) void afterEach(){
provider.shutdown();
}
// Tests that use the provider with the extension
}
The LifecycleExtension
will now execute before the HelpfulExtension
. What this extension does is look for the methods annotated with a @Lifecycle
annotation, of which there are four types: BEFORE_ALL
, BEFORE_EACH
, AFTER_EACH
, and AFTER_ALL
, mapping to the standard JUnit 5 lifecycle callbacks of the same name.
When the test executes, the LifecycleExtension
will find all of the lifecycle-annotated methods and execute them in the order they are discovered, then the other extensions will be applied, and then finally the callbacks for the test itself.
In our example, the beforeEach()
and afterEach()
methods are called early enough to configure the provider
instance so that everything works as it should.
Similarly, the extension has support for BEFORE_ALL
and AFTER_ALL
static lifecycle methods.
Note that the types of method allowed for each lifecycle extension point is as follows:
Lifecycle Point | Method modifier |
---|---|
|
|
|
non- |
|
non- |
|
|
Tip
|
If your lifecycle annotated methods are not being executed, be sure that your method signature meets the criteria described above. |
DatabaseExtension
The DatabaseExtension
is provided as a framework-agnostic means of setting up and tearing down a database, using a provided DataSource
instance.
Before Each
The DataSource
used is created or resolved before each test based on the @PrepareDatabase
. The @PrepareDatabase
annotation defines a creator
property, whose value is the name of a method used to create the DataSource
. The method must return a DataSource
instance. The method used to create the database is resolved in the following order:
-
If the test method is annotated with the
@PrepareDatabase
annotation and it has a value set for thecreator
property, that value will be used as the name of the "creator" method, which will be executed to create theDataSource
. -
If the test class is annotated with the
@PrepareDatabase
annotation and it has a value set for thecreator
property, that value will be used as the name of the "creator" method, and it will be executed to create theDataSource
. -
If a method exists on the test class with the following signature:
DataSource createDataSource()
, it will be used to create the `DataSource'. -
Lastly, if a field exists with type
DataSource
, it will be used as the data source - this field must have a value associated with it before the extension before-each callback is run. Consider using the LifecycleExtension to ensure that it is ready when needed.
Before the test method is run, the extension also allows for one or more setup scripts to be executed. The @PrepareDatabase
annotation provides a setup
property, taking one or more String
values. These values will be used as classpath resource paths to load SQL script content. They are resolved in the following manner:
-
If the test method is annotated with
@PrepareDatabase
:-
if the
additive
property istrue
(the default) and thesetup
property is defined:-
if the test class is annotated with
@PrepareDatabase
and it has a value for thesetup
property, it’s scripts will be executed against theDataSource
-
then, the scripts defined in the method annotation will be executed against the
DataSource
.
-
-
if the
additive
property isfalse
and thesetup
property is defined:-
each of the values in the
setup
property will be read and executed on theDataSource
.
-
-
-
If the test class is annotated
@PrepareDatabase
and it has a value for thesetup
property, those scripts will be executed on theDataSource
.
Tip
|
If you are using a database migration tool like liquibase or flyway, you can still use it to build your schema by running it in your "creator" method and then tearing it down in your "destroyer" method. The "setup" and "teardown" scripts could still be used to populate the database if needed. |
After Each
After each test method is run, the extension allows for one or more tear-down scripts to be executed. The @PrepareDatabase
annotation provides a teardown
property, taking one or more String
values. These values will be used as classpath resource paths to load SQL script content. The scripts are resolved in the following manner.
-
If the test method is annotated with
@PrepareDatabase
:-
if the
additive
property istrue
(the default) and theteardown
property is defined:-
if the test class is annotated with
@PrepareDatabase
and it has a value for theteardown
property, it’s scripts will be executed against theDataSource
-
then, the scripts defined in the method annotation will be executed against the
DataSource
.
-
-
if the
additive
property isfalse
and theteardown
property is defined:-
each of the values in the
teardown
property will be read and executed on theDataSource
.
-
-
-
If the test class is annotated
@PrepareDatabase
and it has a value for theteardown
property, those scripts will be executed on theDataSource
.
The DataSource
is "destroyed" using a "Destroyer Method" after each test is executed. Similar to the "Creator Method", the destroyer method is defined by the destroyer
property of the @PrepareDatabase
annotation. The method must accept a DataSource
parameter and is responsible for performing any cleanup or shutdown operations required by the DataSource
. It will be resolved in the following order:
-
If the test method is annotated with the
@PrepareDatabase
annotation and it has adestroyer
property defined, that value will be used as the destroyer method name. It will be executed with theDataSource
passed into it. -
If the test class is annoated with the
@PrepareDatabase
annotation and it has adestroyer
property defined, that value will be used as the destroyer method name, and it will be executed with theDataSource
passed into it. -
Lastly, if a method exists on the test class with the following signature:
void destroyDataSource(DataSource)
, it will be executed to perform the destruction handling, giving theDataSource
passed to it.
DataSource
Parameter
If a test method is given a DataSource
argument, it will be populated with the resolved DataSource
for use in the test method.
Example
The following is an example with all the bells and whistles - see the unit tests for more scenarios.
@ExtendWith(DatabaseExtension.class) @PrepareDatabase(
creator="createDs",
setup={"/db-create.sql", "/db-init-data.sql"},
teardown={"/db-destroy.sql"},
destroyer="destroyDs"
)
class SomeRepositoryTest {
DataSource createDs(){
// create your DS here...
}
void destroyDs(final DataSource ds){
// destroy your ds here...
}
@Test @PrepareDatabase(setup="/add-more-data.sql")
void testing(final DataSource ds){
// do your testing
}
}
In this example, the testing
method would use the "creator", "destroyer", and "teardown" values from the annotation on the class, while the "setup" scripts would come from both the class list and the one provided in the method annotation. The DataSource
argument is populated by the parameter resolver.
SharedRandomExtension
The SharedRandomExtension
is used to test randomized scenarios in a way that removes the randomness for repeatable testing. It may seem odd to disable the randomness, but when you are trying to fix a failing test case, you will want to pin the "random" value so that you can fix the test and ensure that it succeeds.
This extension will only work with classes that use the SharedRandom
class to provide their randomization. This includes the Randomizers
defined in this library, which is actually what it was created to test.
The extension will set a known seed value on the random generator so that it is no longer random. By default, a shared
known seed will be used (see DEFAULT_KNOWN_SEED
); however, this may be overridden by a configured value in your test
class or by the @ApplySeed
annotation on your test method.
You can specify your own seed value by adding a field to your class with the signature private static final long KNOWN_SEED = <your-value>
to your test class. This provided value will be used instead of the default (it does not have to be private
).
Alternately, if your test method is annotated with the @ApplySeed
annotation, its value will be used as the seed for that test method.
The random generator is reset after each test by setting the seed to the current nanoTime()
value (i.e. making it "random" again).
A simple example of this extension in a test would be:
@ExtendWith(SharedRandomExtension.class)
class YourInterestingTest {
@Test @ApplySeed(8675309L)
void tester(){
val rand = SharedRandom.current();
assertEquals(8675309L, ((SharedRandom) rand).getSeed());
assertEquals(-4523360879423753120L, rand.nextLong());
}
}
The specified seed, 8675309
will be used in the SharedRandom
, allowing the "random" values to be predictable.
Note
|
In case you are not aware, the seed-based random number generation is not really random - if you use the same seed, you get the same "random" values in the same order, which is the basis for this method of testing. |
SystemPropertiesExtension
The SystemPropertiesExtension
is used to update the System properties with a configured set of properties, resetting it back to the
original values after each test.
In order to provide the property values to be injected, you must provide either a Properties
or Map<String,String>
object named "SYSTEM_PROPERTIES" on the test class as a static field.
Alternately, you may specify the field name containing your properties using the @ApplyProperties
annotation on the test method.
Before each test method is executed, the configured properties will be injected into the System properties; however, the original values will be stored and replaced after the test method has finished.
@ExtendWith(SystemPropertiesExtension.class)
class SystemPropertiesExtensionPropertiesTest {
@SuppressWarnings("unused")
static final Properties SYSTEM_PROPERTIES = asProperties(Map.of(
"first.name", "Bob"
));
@SuppressWarnings("unused")
static final Properties OVERLAY = asProperties(Map.of(
"first.name", "Fred"
));
@Test void checkValues() {
assertEquals("Bob", getProperty("first.name"));
}
@Test @ApplyProperties("OVERLAY")
void checkOverlayValues(){
assertEquals("Fred", getProperty("first.name"));
}
}
Note
|
Due to the global nature of the System properties, the test methods under this extension are locked so that only one should run at a time - that being said, if you run into odd issues, try executing these tests in a single-threaded manner (and/or report a bug if you feel the functionality could be improved). |
ResourcesExtension
The ResourcesExtension
provides for the injection of classpath resource paths or content based on object type annotated with the @Resource
annotation - the supported types are as follows:
-
A
Path
will be populated with the path representation of the provided classpath value. -
A
File
will be populated with the file representation of the provided classpath value. -
A
String
will be populated with the contents of the file at the classpath location, as a String. -
An
InputStream
will be populated with the content of the file at the classpath location, as an InputStream. -
A
Reader
will be populated with the content of the file at the classpath location, as a Reader. -
A byte array (
byte[]
) will be populated with the content of the file at the classpath location, as a array of bytes. -
Any other object type will attempt to deserialize the contents of the file at the classpath location using the configured
serdes
value of the annotation (defaulting toJacksonJsonSerdes
if none is specified.
The annotated types may be:
-
Static Fields. A
static
field annotated with the@Resource
annotation will be populated during the "BeforeAll" callback. -
Non-Static Fields. A non-
static
field annotated with the {@link Resource} annotation will be populated during the "BeforeEach" callback. -
Callback or Test Method Parameters. A lifecycle callback or test method parameter annotated with the
@Resource
annotation will be populated when that method is called by the test framework.
A contrived example could look something like the following:
@ExtendWith(ResourcesExtension.class)
class SomeTest {
@Resource('/resource-01.dat') static byte[] resourceData; // injected during BeforeAll
@Resource('/resource-02.txt') String someText; // injected during BeforeEach
@Test void testing(
@Resource(value="/person.xml", serdes=JacksonXmlSerdes.class) final Person person
){
// testing with the instantiated person (from xml)
}
}
The resource loading provided by this extension delegates to the Resources
utility methods, which may be used directly - this extension provides a simplification framework for common use cases.
Note
|
All injected fields will be cleared (set to null) during the appropriate "after" callback. |
LogAppenderExtension
The LogAppenderExtension
provides a test configuration framework for the InMemoryLogAppender
, which allows for test collection of log messages for test result verification.
Before each test method, the extension will resolve the log appender configuration as one of the following:
-
If the test method is annotated with the
@ApplyLogging
annotation, its value will be used as the name of a field of typeAppenderConfig
. -
Otherwise, a field of type
AppenderConfig
with the nameAPPENDER_CONFIG
will be used.
The InMemoryLogAppender
is configured with the AppenderConfig
and may be accessed by the test method by adding a parameter to the method of type InMemoryLogAppender
.
After each test method, the registered loggers will be detached as part of the cleanup.
An example would look something like the following:
@ExtendWith(LogAppenderExtension.class)
class SomeServiceTest {
private static final AppenderConfig APPENDER_CONFIG = AppenderConfig.configure()
.loggedClass(SomeService.class);
private static final AppenderConfig OTHER_CONFIG = AppenderConfig.configure()
.loggedClass(SomeService.class)
.filter(evt -> evt.getLevel().isGreaterOrEqual(WARN));
@Test void testing(final InMemoryLogAppender appender){
// your testing with the APPENDER_CONFIG
}
@Test @ApplyLogging("OTHER_CONFIG")
void otherTesting(final InMemoryLogAppender appender){
// your testing with the OTHER_CONFIG
}
}
Hamcrest Matchers
The test-things library provides a collection of useful Hamcrest matchers to aid in testing.
Note
|
Below are some of the provided matchers - see that Java Docs for a comprehensive list. |
Files. The FileMatcher
provides matchers for many commonly-used file-related properties.
assertThat(file, allOf(isFile(), fileExists(), isReadable()));
Date. The ChronoLocalDateMatcher
supports the matching of date objects implementing the ChronoLocalDate
interface, such as LocalDate
instances.
assertThat(someDate, ChronoLocalDateMatcher.isAfter(otherDate));
Date Time.* The ChronoLocalDateTimeMatcher
supports the matching of date-time objects implementing the ChronoLocalDateTime
interface, such as the LocalDateTime
instances.
assertThat(someDateTime, ChronoLocalDateTimeMatcher.isBefore(otherDateTime));
Date Operations. The TemporalMatcher
supports matching of various Temporal
fields.
assertThat(someDate, TemporalMatcher.isAfternoon());
Predicate Wrapper. The PredicateMatcher
wraps a Predicate<T>
instance to allow it to be used as a Hamcrest matcher.
assertThat(something, PredicateMatcher.matchesPredicate(v -> v > 100));
Byte Arrays. The ByteArrayMatcher
provides a set of matchers for byte[]
arrays, which can be tricky in testing.
assertThat(bytes, ByteArrayMatcher.arrayStartsWith(someBytes));
Atomics. The AtomicMatcher
provides some matchers for matching "atomic" objects.
assertThat(counter, AtomicMatcher.atomicIntIs(equalTo(42)));
Verifiers & Resources
The library provides a Resources
utility class which contains numerous methods for loading and working with classpath resources for testing. It also provides a Verifiers
utility class (and other utilities with the suffix Verifier
) to provide some useful common test verification methods.
Injectors
Often during testing, especially with 3rd-party libraries, you need to get some data into or out of an object that does not provide a clean means of access. In this case, reflection is your friend, but it’s an annoying friend that doesn’t always do what you want without argument.
The "injector" framework in test-things, provides a simpler means of injecting values into and extracting values out of objects with simple configuration. Also, it plays nicely with the "randomizer" framework also provided in this library.
Consider the following contrived class example, maybe from some 3rd-party library you can’t change:
public class Structure {
private int code;
public Structure(final int code){
this.code = code * 42; // maybe it does some stuff with the code
}
public int getCode(){
return code;
}
// it does other stuff with the code field
}
For a test case we are working on we want to modify the code
value after construction, but it’s only provided in the constructor. Using the Injector
we can update the value as needed:
var injected = Injector.inject(new Structure(1010), inj -> {
inj.set("code", 2020);
});
// the value of "code" is now 2020
In the test, the set(String,Object)
method is used to inject the desired value into the instance. When the injection is performed, it is done on the same object instance, not a clone or copy - this is not mocking, it’s just putting data into an existing field. If you inspect the object instance with the debugger you would see that the value of the code
field is 2020
after the injection.
So at this point, this is nothing all that earth-shattering, it’s just reflection. The fun parts come with the flexibility of the configuration api.
In the previous example, we used the set
injector to directly inject a field value. There is also a version of this method which will first try to use a setter method. For example, if the field is named foo
and there is a method void setFoo(value)
, the injector will first try to use the setter, and if that is not present it will directly set the field value - this is an optional behavior.
Another means of injection is the update
injector. Consider our example above. Maybe we only wanted to double the current value of the code
field rather than setting it specifically. We could do something like the following:
var injected = Injector.inject(new Structure(1), inj -> {
inj.update("code", c -> c * 2);
});
// the value of "code" is now: 42 x 1 x 2 = 84
The update
injector applies the provided function to the current value of the specified field - in this case the value of code
is 42
multiplied by 2
which ends up being 84
. The value returned by the function replaces the current value.
Another useful case is when you have a mutable object in a field that you need to modify. You can use the modify
injector to act on the current value without replacing it, such as in the following:
public class Something {
private final Map<String, Integer> counts = new HashMap<>();
// other code
}
The counts
field has a map that we want to update by changing its contents, not its value. We can use the modify
injector to do that:
var injected = Injector.inject(new Something(), inj -> {
inj.modify("counts", m -> {
m.put("alpha", 100);
m.put("bravo", 200);
});
});
This updates the contents of the map without replacing it.
There is no limit to the number of injections you can configure - each configuration consumer can apply multiple injectors as needed.
Lastly, if the value to be injected is an instance of the Randomizer
interface, the one()
method will be called to generate a random value which will be used in the injection.
Randomizers
Testing with randomized values may sound like an odd concept, but it does have its uses. Consider a case where there are too many permutations of a scenario to adequately test all of them. You could create a randomized set of test values to run against and run your test multiple times - sure, you still don’t hit them all, but you may stumble on a set that does fall into some hidden bug that your static tests would not have found.
The Test-Things library provides a Randomizer<T>
interface to define a means of randomly generating objects or values. It’s primary method of interest is the T one()
method, which generates one randomized object of the specific type, though you can generate multiple random instances using the List<T> many(int)
method as well.
The library provides a handful of Randomizer<T>
implementations, including the ObjectRandomizers
which allow you to build more complex randomized objects using randomized values for the fields and properties of a given object - combining injectors with randomizers.
Given some class for which to generate random instances, such as:
public class Thing {
ThingType type;
int count;
private String name;
public void setName(final String name){
this.name = name;
}
public String getName(){
return name;
}
}
You can generate random instances of Thing
using:
val rando = ObjectRandomizers.randomized(new Thing(), inj -> {
inj.setField("type", CoreRandomizers.oneOf(ThingType.class));
inj.setField("count", NumberRandomizers.anIntBetween(0,1000));
inj.setProperty("name", StringRandomizers.alphabetic(CoreRandomizers.constant(6)));
});
Which would generate a random Thing
by directly injecting the type
and count
fields using values from the provided randomizers, while also injecting the random value for the name
field by first trying to use the "setter" for the property.
With this framework, you can generate complex random instances as simply as you can generate random primitive values.
Tip
|
You can "pin" the randomizers so that they will produce the same values - see the SharedRandom class for details. This allows you to reproduce failing test values.
|
SharedRandom
All of the Randomizer<T>
implementations provided in this toolkit use the SharedRandom
class to provide the random values. This class is based on the standard ThreadLocalRandom
class, but it provides a means of easily overriding the seed value, which is useful for "pinning" the random values for testing.
The seed value may be overridden directly in the instance, or a system property test-things.rando.seed
may be set to configure the JVM-wide seed value to be used.
Capturing SLF4J Logging
Sometimes the result of an operation ends up being a log message, or the lack thereof. This can be a tricky result to unravel, but if you are using the SLF4J logging API, you can use the InMemoryLogAppender
, which is based on the Logback logging implementation (the primary implementation of the SLF4J API).
To capture the log events without a lot of complex mocking, all you need to do is inject your own Appender
- simply put, the Appender
implementations are what collects the log events and renders them in some usable format (e.g log files).
In order to test and verify logging events, you need to create an instance of the InMemoryLogAppender
and configure it with the logged classes that you want it to capture the events of.
class SomeTest {
private final InMemoryLogAppender appender = new InMemoryLogAppender(cfg -> {
cfg.loggedClass(Alpha.class);
cfg.loggedClass(Bravo.class);
});
}
In order for the appender to be registered properly with the logging system, the InMemoryLogAppender::attach()
method must be called once all of the logger configuration has been performed. When the testing is done, the InMemoryLogAppender::detach()
method should be called to clean up the test logger configurations. These operations are best performed in the @BeforeEach
and @AfterEach
methods, such as (adding to the above example):
@BeforeEach void beforeEach() {
appender.attach();
}
@AfterEach void afterEach() {
appender.detach();
}
Then you can run your test target and examine the generated (collected) log events in the appender with the various provided accessor methods.
Note
|
There is also a JUnit 5 extension that makes using this even easier - see the LogAppenderExtension for details. |
Matchers
A few Hamcrest matchers have been provided specifically for use with this log testing framework:
-
The
LogLevelMatcher
matches criteria for the log level of the event. -
The
LogMessageMatcher
matches criteria for the log message string of the event. -
The
LogNameMatcher
matches criteria for the logger name for the event.
Serdes Providers
Some of the resource and verification method require serialization operations to load or verify content based on serialized or deserialized values. In order to keep things as generic as possible, Test-Things provides a SerdesProvider
interface to abstract the actual "Ser"ialization and "Des"erialization operations (SerDes).
Currently, there are three provided implementations:
-
JacksonJsonSerdes
- JSON-based serdes using the Jackson JSONObjectMapper
-
JacksonXmlSerdes
- XML-based serdes using the Jackson XMLXmlMapper
-
JavaObjectSerdes
- standard Java object binary serdes using theObjectInputStream
andObjectOutputStream
Note
|
Some of the serdes-based resource and verification methods are provided without a SerdesProvider parameter, which generally means they are using the JacksonJsonSerdes provider - a sensible default.
|
Other Useful Testing Libraries
JUnit 5. The gold standard for Java unit testing frameworks - though if you are reading this, you probably already know that.
Ersatz Server. A useful framework for testing HTTP client code against a real server with pre-determined responses and expectations. A sister project to this library.
Hamcrest. An expressive framework for assertion matchers - the matchers in test-things are based on this framework.
Awaitility. Library providing a means of testing asynchronous code in a stable and repeatable manor.
Mockito. A powerful mocking and stubbing library.
Appendices
A. Development Philosophy
As with my other project, the Ersatz Server, I intend to keep this project as clean and useful as possible, without holding on too tightly strict rules.
Being that this is a project used in writing unit tests, I don’t generally feel the need for strict backwards compatability as long as there is a simple upgrade path. That being said, if some change causes a major problem, I am not against cutting a new release with changes that make the transition easier.
B. License
This project is licensed under the Apache 2.0 License.
Copyright (C) 2023 Christopher J. Stehno
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.