A final project by Andrew Chang-DeWitt for CSCI 24000 at IUPUI, Spring 2022.
A simple unit testing library for TDD & a cli for running tests.
- Intended User: Java developers to write & evaluate unit tests.
- Problem solved: Unit test writing & evaluation.
- Technologies needed: CLI commands, file i/0, programmatic/dynamic compilation, & output to stdout
Features belong to one of three categories: definition, running, & discovery.
Defining features here via User stories. In this case, a User is defined as the person writing & running tests.
Encompasses features that are used to write, group, set up, & tear down tests.
- A User defines a group of tests as methods belonging to a class.
- A User describes a group of tests by giving a string description as the
desc
property on the class. - A User describes a test method by declaring it with a descriptive name, beginning with the prefix
test
. - A User can nest groups of tests by declaring a nested class.
- A User can define code that needs to be run once before executing a group of tests by defining a method called
before
. - A User can define code that needs to be run before each test in a group of tests by defining a method called
beforeEach
. - A User can define code that needs to be run once after executing a group of tests by defining a method called
after
. - A User can define code that needs to be run after each test in a group of tests by defining a method called
afterEach
Encompasses features for executing tests, evaluating for success or failure, & communicating that state to the user.
- A User can run a test class (& any nested classes) manually by defining a main() method that creates an instance of itself & calls the instance's run() method.
- A User receives a concise indicator of test result status after running a test class with a '.' to indicate a successful test & an 'F' to indicate a failure.
- A User receives a count of successful tests out of total tests at the end of the report.
- A User receives a detailed report consisting of the test class name, followed by a list of test method names & success or failure indicators, depending on the result.
- A User receives details about any failed test, including the test class name, test method name, failure reason (exception name & message), & any stack trace.
- A User can opt to receive a concise output of test result status, skipping the printing of each test group or test method's text and status
Encompasses features for recursively finding all tests in a given directory & running them.
- A User can use a cli command (e.g.
jspec test
ormaven test
) have jspec recursively traverse a given (could default to PWD or project) directory & subdirectories & discover any files ending inSpec.java
, then run the tests for all files found. - A User can give a glob pattern to use when searching for spec files, replacing the default
Spec.java
pattern. - A User can specify a single test file to run.
-
What data is your program really about?
In this case, the 'data' in question is code: a test is simply code used to set up & declare a fact, then evaluate if it is true or not. So to that end, data starts as code, then is transformed to a 'Success' or 'Failure' statement by evaluating the test's assertions.
-
What is the best way to represent that data? (database, object, arrays)
The data will be represented as Objects defined by the User & inherited from an Object provided by this library. Each Object will contain tests defined as methods on the Object.
-
Will the data need to be persistent? How will you make that happen?
The only data persistence will be the tests defined by the User in
*.java
files. For the most simple use case, there won't be any File I/O, as the User will set up the test Object to be compiled & run using the Java compiler & runtime. In more complex use cases, the User may use a CLI provided by the library that will utilize File I/O APIs to "discover" tests & then evaluate them. -
Will the data need to be aggregated into a larger structure?
In the simple use case, the User will handle aggregation by defining their own
Runner
Object with its ownmain()
method that addsGroup
s to theRunner
before evaluating theGroup
s. When using the CLI, the program will need to aggregateGroup
s into aRunner
that is then used to evaluate the tests. In both cases, theRunner
will provide two options for addingGroup
s: via theRunner()
constructor during initialization, or by calling theRunner.addGroup()
method after initialization. Internally, theRunner
will aggregateGroup
s in an array or vector. During test evaluation, the collection will be traversed & theGroup
s will be inspected usingjava.lang.reflect
APIs to discover all test methods & any nestedGroup
s. As tests & test groups are discovered they could either be evaluated immediately, or have references to them stored in a tree that will later be traversed for the actual evaluation of each test.
The main portion of the UI is the text output of test results. This is mainly broken down into 4 parts:
- A progress indicator (using
.
&F
to represent a test's completion & result) - A failure result (detailed output regarding a failed test, incl. stack trace, error type/message, test-writer messaging, maybe even code snippets?)
- Verbose status output (Using Group's descriptions & test descriptions to generate output as a series of nested lists)
- Summary (a count of all tests, a count of passed tests)
Sample output for each part below.
.....F....F..F.....
================================================================================
❌ FAILURE: Some group name/description: Some test name/description
--------------------------------------------------------------------------------
SomeErrorType: An error message
Some Method() nn:mm in SomeModule
Some Method() nn:mm on SomeClass
Some Method() nn:mm in SomeModule
Some Method() nn:mm on SomeClass
Some Method() nn:mm in SomeModule
Some Method() nn:mm on SomeClass
Some Method() nn:mm in SomeModule
Some Method() nn:mm on SomeClass
Assume 4 Groups, A, B, C, & D. C is nested in B, & B is nested in A; D is unrelated.
Group A description
Some test description ✅
Another test description ❌
testATestMethodName ✅
testSomeOtherTest ❌
Group B description
Some test description ✅
Another test description ❌
Group C description
Some test description ✅
testSomeOtherTest ✅
Group D description
Some test description ✅
Another test description ✅
testATestMethodName ✅
testSomeOtherTest ✅
Some failure(s):
================================================================================
9/15 (60%) Passed
No failures:
================================================================================
15/15 (100%) Passed!
The program is strictly Object-Oriented with all code organized by classes. A UML diagram is provided here, with more details about each class below it.
OOP Relationships:
- A User's test definition class extends Group
- A User's test runner class extends Runner
- One Group aggregates zero to many Group
- One Runner aggregates one to many Group
- One Node aggregates one Result
- One Node<T> aggregates zero to many Node<T>
- One Tree<T> aggregates one Node<T>
- ResultsTree extends Tree<Result>
- One Runner aggregates one ResultsTree
- One CLI aggregates one Runner
- One CLI composes one Crawler
A User creates a Group
of tests by defining a new class that inherits from this class.
Nested groups are created by defining inner classes (that inherit from Group
) inside a child of Group
.
Most User stories belonging to the Test Definition group above will be built as features on Group
.
- protected static String desc: a property used to give a better description to a test group, defaults to null if not implemented in a child class
-
public visit() -> VisitResults
- Get the
Class
object for thisGroup
instance - Get a list of methods for the instance
- Get a list of inner classes for the instance
- Return a new instance of VisitResults containing:
- A list of test results created using
Group.evaluate()
- A list of inner Groups found using
Group.findChildren()
- A list of test results created using
- Get the
-
private evaluate( Methods[] tests, Class<? extends Group> instanceClass, boolean silent) -> DoublyLinkedList
- Create an empty list to store results
- Perform setup tasks using
this.before()
- Loop over the given list of method:
- Create new
Result
- If this
Group
has a non-nulldesc
attribute, add it to the result - Try the following:
- Perform per-test setup using
this.beforeEach()
- Invoke the test method
- Perform per-test teardown using
this.afterEach()
- Mark the result as a passed test
- Perform per-test setup using
- Catch InvocationTargetException
- Mark result as a failed test with caught target exception
- If not silent:
- If caught exception was caused by an AssertionError, print an "F"
- Else print an "E"
- Cath IllegalAccessException
- Mark result as a failed test with caught exception
- If not silent print an "E"
- Create new
- Perform teardown using
this.after()
- Return the list of results
-
private findChildren( Class<?>[] nested, Group parent) -> DoublyLinkedList
- Create an empty list to store children
- For each given nested class:
- If the nested class is a descendent of Group, try the following:
- Get the class's constructor & initialize an instance of it
- Append the instance to the list of children
- Catch IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchMethodException exceptions:
- Print a helpful error message
- Print the exception
- If the nested class is a descendent of Group, try the following:
-
Virtual functions:
The following methods are defined as "virtual" functions that are all a no-op if not defined in a child class. Each is a method that will be called a different stage of test execution: once before any tests are called, once before each test method is called, once after each test is called, & finally once after all the tests are called. A child class can implement any or all of these to customize some behavior needed for all tests or perform some set-up or tear-down actions.
- public before() -> void
- public beforeEach() -> void
- public afterEach() -> void
- public after() -> void
A simple data class that stores a list of Group
s and a list of Result
s.
A User creates an instance of this to run any test Group
s passed to it on creation, or by adding them to the Runner
instance using the addGroup()
method.
import org.jspec.Runner;
class Runner extends Runner {
public static void main(String[] args) {
// Add test groups during Runner initialization
Runner r = new Runner(GroupA, GroupB);
// Add a group using Runner.addGroup()
r.addGroup(GroupB);
// run the tests in all groups given
r.run();
}
}
The CLI will work by auto-discovering test Group
s in a directory & subdirectory, then creating a new Runner
instance & giving all the found Group
s to the new Runner
.
Most likely, the User will never need to implement this class, but it's exposed if they would like to run their tests programmatically.
- private DoublyLinkedList<Group> groups: stores the groups to be evaluated by this
Runner
- private ResultsTree results: stores the results after the groups are evaluated
- int totalTests: for counting the number of tests executed when running all the Groups' tests
- int failedTests: for counting the number of failed tests
-
public Constructor Runner(Group ... groups)
-
public Constructor addGroup(Group group)
- Build Runner containing the given
Group
(s)
- Build Runner containing the given
-
public run() -> Runner
- Create a
ResultsTree
onthis.results
with an empty root - If not silent, print an empty line to pad output
- Loop over
this.groups
passing each group tothis.buildResults
- Create a
-
private buildResults(ResultsTree tree, Group group, boolean silent) -> void
- Create a
Node
containing aResult
made from thisGroup
- Add newly created
Node
to the givenResultsTree
- Call the
Group
's visit method, storing the results - Make a new
ResultsTree
pointing to the newNode
created above as its root - Loop over the test results from calling
visit()
above, adding each result as a child node to the newly created tree - Loop over the children gotten from calling
visit()
above, callingthis.buildResults()
on each of them to continue building tree recursively - Return the given tree
- Create a
An abstract base class for defining a result value to store in a tree. Defining shared properties & one shared method, all used to render the tree of values.
- private String codeName: the name of the method or class in the test code that this result is from
- private String descName: a more descriptive name given to this class or test method, may be null
- private boolean testResult: indicates if
Result
is for aGroup
or a test method - private boolean pass: indicates if the test passed or not
- private Throwable exc: stores the exception thrown when a test failed
-
Constructor public Result(String codeName)
- Set
this.codeName
to given string - Set as not a test result
- Set
-
public describe(String description) -> Result
- Set
this.descName
to given string - Return this
- Set
-
public pass() -> Result
- Mark result as passed
- Mark result as being for a test
- Return this
-
public fail(Throwable exc) -> Result
- Mark result as failed
- Mark result as being for a test
- Set
this.exc
to given exception - Return this
-
public statusString(String prefix) -> String
- Create a result string, starting with the name from
this.getName()
- If
Result
is for a test, append with a ✅ or ❌ indicating it passed or failed - Prepend with given prefix & return
- Create a result string, starting with the name from
-
public failureStrings(String prefix) -> DoublyLinkedList<String>
- If
Result
is a test, create a newDoublyLinkedList
of Strings- Then append it with two empty lines
- Then a line of "="
- Then "❌ FAILURE:" followed by the
Result
s name - Then a line of "-"
- Then the
this.exc
's String representation - Then
this.exc
's stack trace lines - And finally an empty line
- Else, throw an error
- If
Getters:
- public getName() -> String
- public getCodeName() -> String
- public getDescription() -> String
- public getFailureExc() -> Throwable
- public didPass() -> boolean
- public isTest() -> boolean
Encapsulates logic for building a collection of nodes.
- private T value: the actual value object
- private Node<T> parent: a reference to the parent of this node, will be null if this is a root node
- private Node<T> headChild: a reference to one end of the list of child nodes (child nodes are represented as a doubly linked list), will be null if this is a leaf node
- private Node<T> tailChild: a reference to the other end of the list of child nodes (child nodes are represented as a doubly linked list), will be null if this is a leaf node
- private Node<T> nextSibling: a reference to the next sibling of this node (siblings a linked list), will be null if this is the tail child
- private Node<T> prevSibling: a reference to the previous sibling of this node (siblings a linked list), will be null if this is the head child
-
Constructor public Node(T value)
- Assign
value
tothis.value
- Assign
-
Builder pattern:
These methods all implement a Builder Pattern for creating new nodes. First the creator calls the constructor with the desired value, then sets any parent, child, or sibling nodes using the appropriate method below. These method calls can be chained, as they all return the instance of
Node<T>
after adding the given parent/child/sibling node.- public addParent(Node<T> node) -> Node<T>
- public addHeadChild(Node<T> node) -> Node<T>
- public addTailChild(Node<T> node) -> Node<T>
- public addNextSibling(Node<T> node) -> Node<T>
- public addPrevSibling(Node<T> node) -> Node<T>
-
Getters:
These methods are all simple getters for the associated private property.
- public getParent() -> Node<T>
- public getHeadChild() -> Node<T>
- public getTailChild() -> Node<T>
- public getNextSibling() -> Node<T>
- public getPrevSibling() -> Node<T>
-
Deleters:
These methods set the
Node
's referenced sibling tonull
. Additionally, they edit the previously referenced sibling to remove its reference to thisNode
—e.g. ifA
hasB
as a next sibling, then callingA.removeNextSibling()
will setA
's next sibling tonull
&B
's previous sibling tonull
as well.- public removeNextSibling() -> void
- public removePrevSibling() -> void
-
Setters: There is currently no use case where setters will be needed as the Results Tree is build & never modified.
Encapsulates logic for traversing & manipulating a tree via a reference to the root node.
- private Node<T> root
-
public Tree(Node<T> node) -> Tree<T>
Create a tree with the given root node.
-
public appendChild(Node<T> node) -> Tree<T>
-
public prependChild(Node<T> node) -> Tree<T>
Append/Prepend given
Node
to the root's list of children. -
public getChildren(Node<T> node) -> DoublyLinkedList<T>
Return a list of the root node's children.
-
public reduce( ReduceConsumer<T,U> action, U initialValue) -> U
Traverses the tree with a pre-order algorithm, executing the given
ReduceConsumer
for each node & returning the new data structure.- Call
this.reducer()
with givenReduceConsumer
, initial value, & a starting depth of 0.
- Call
-
public reducer( ReduceConsumer<T,U> action, U accumulator, int depth) -> U
- Call given
ReduceConsumer.accept()
onthis.root
with the given accumulator & depth, saving the result as an updated accumulator - Reduce the list from calling
this.getChildren()
, doing the following for eachNode
:- Create a new
Tree
with the node as it's root - Call the new tree's
reducer
method with the given action, the updatedaccumulator, & the current depth + 1
- Create a new
- Call given
-
public forEach(ForEachConsumer action) -> void
Loops over the tree in a pre-order traversal, executing the given
ForEachConsumer
for each node. Usesthis.reduce()
to reuse the pre-order logic. -
public map(MapConsumer<T,U> action) -> Tree<U>
Loops over the tree in a pre-order traversal, executing the given
MapConsumer
for each node to build a newTree
with the result of each call.- Create an object to track the relationships of previously traversed nodes relative to the current node
- Call
this.reduce
, doing the following for each node:- Call the given consumer on the current node's value, creating a new node from the result
- If there was no previous node:
- Push the current node to the stack of parent nodes
- Save this node as the new previous node
- And make a new Tree with this node as the root
- Else if the current node's depth is greater than the previous node's depth:
- Set previous node depth to the current node depth
- Push the previous node to the stack of parent nodes
- Create a new tree from the previous node & append the current node as a child
- Else if the current node's depth is equal to the previous node's:
- Get the tree with the parent node at the top of the stack as it's root, then append the current node as a child node
- Else if the current node's depth is less than the previous node's depth:
- Then pop nodes off the stack of parent nodes until we've returned to the current depth
- Get the tree with the new parent node at the top of the stack as it's root, then append the current node as a child node
- Return the tracker object to be used when processing the next node
- Return the tree stored in the tracker object
-
public find(FindPredicate predicate) -> Node<T>
Returns the first node (using a pre-order traversal) that satisfies the given predicate. Uses
this.reduce
to reuse the traversal logic. -
public find(T value) -> Node<T>
Returns the first node (using a pre-order traversal) that matches the given value.
-
public contains(T value) -> boolean
Returns true if the Tree contains a node with the given value.
-
public iterator() -> Iterator<T>
Returns an
Iterable\<T\>
from the given tree that follows a pre-order traversal.
Encapsulates logic for crawling the project file tree for test definition files & getting the defined Group
descendants from them.
Extends java.nio.file.SimpleFileVisitor
.
- private File start: the file to start crawling the file tree at
- private PathMatcher pattern: the pattern to match files to
- private CrawlHandler handler: a function to execute if a file matches the pattern
- private CrawlExcHandler excHandler: a function to execute if there's an error processing a file
-
Constructor public Crawler(File start, String pattern)
- Save start file
- Get
PathMatcher
from given pattern string
-
public crawl( CrawlHandler handler, CrawlExcHandler excHandler) -> Crawler
- Set properties to given handlers
- Use
java.nio.file.Files.walkFileTree
to walk file tree starting atthis.start
& using thisCrawler
object as theFileVisitor
-
<<override>> public visitFile( Path file, BasicFileAttributes attrs) -> FileVisitResult
If file path matches
this.pattern
, callthis.handler
on given file. -
<<override>> public visitFileFailed( Path file, IOException exc) -> FileVisitResult
Call
this.excHandler
w/ given path & exception.
Main entry point for the command line interface program.
Encapsulates logic to receive commands & arguments, then dispatches commands & composes results accordingly.
Implements the Callable
interface & uses remkop/picocli to build CLI features.
- File cwd: the current working directory
- Runner runner: an instance of
Runner
- boolean concise: a flag for concise vs verbose output
- String pattern: the search pattern for finding test files, defaults to "**/*Spec.java"
-
public static main(String[] args) -> void
- Run the CLI with given args using
picocli.CommandLine.execute(args)
- System exit code to resulting int
- Run the CLI with given args using
-
Constructor public CLI()
- Init an empty Runner()
- Get current working directory from
System
-
public call(String[] args) -> int
- Start tracking time
- Discover & compile test classes w/
this.discover
- Mark compile time duration
- Run tests
- Mark run time duration & total time duration
- Report times to user
- Return 0 to indicate success
-
private discover(File start, String pattern) -> void
- Create an empty list to store paths to test files
- Init a
Crawler
with cwd & given pattern - Then initiate crawl with success handler that adds a matching file to the list & an error handler that indicates there was an error processing a given file
- Compile and initialize test
Group
s usingthis.compileAndInitFiles
, then add each initializedGroup
tothis.runner
- Catch and report errors thrown while crawling file tree
-
private compileAndInitFiles(ArrayList paths) -> ArrayList<Group>
- Set up compiler & java file manager
- Get list of source File objects from given paths
- Compile files
- Loop over given list of paths:
- Get the fully qualified name for each path's class
- Initialize the class from the fqn
- Add the initialized
Group
to the list of Groups to return
-
private run() -> void
- Run all
Group
s inthis.runner
- Loop over
this.runner.resultStrings()
to print each line
- Run all
OOP:
- Inheritence -
ResultsTree
-|>Tree\<Tree\>
- Encapsulation
- Polymorphism
- Abstraction
Data structures:
- ArrayList
- Doubly Linked List
- Stack
- n-ary Tree
Algorithms:
- pre-order tree traversal
- functional collection transformation (i.e. map, reduce, forEach)
- file-tree traversal
© Andrew Chang-DeWitt 2022