-
Notifications
You must be signed in to change notification settings - Fork 287
Writing Unit Tests
Unit tests operate by running small parts of the code base as if they were in the production environment and asserting that the result matches some criteria. This requires:
- a suitable environmental setup appropriate to the function or code under test;
- a method of determining that the result matches expectations.
The MegaMek project uses JUnit and Mockito to accomplish this.
"JUnit is a test automation framework for the Java programming language. JUnit is often used for unit testing, and is one of the xUnit frameworks."
In MegaMek testing, JUnit provides overall unit test framework, as well the assert functionality that validates output against expected results. Some keywords and concepts from JUnit include:
-
@Test
: JUnit annotation indicating a function that the JUnit framework will run. -
@BeforeAll
: JUnit annotation indicating a setup function that must run before any tests or other setup is run. Requirespublic static
function. -
@BeforeEach
: JUnit annotation indicating a setup function that must run before each test. Often used to create the test environment or prerequisites. -
@TempDir
: JUnit annotation defining an on-disk test directory that JUnit must create prior to tests running, and clean up afterward. Useful for file operation tests. -
assertTrue(boolean)
: a test function that throws a special exception if the provided boolean value is nottrue
. -
assertFalse(boolean)
: likewise, asserts if the provided boolean value is notfalse
. -
assertEquals(<expected>, <actual>)
: throws if the actual value (the result) does not match the expected value (the requirement).
At a minimum, a JUnit unit test will make use of @Test
and one or more assert*
functions.
"Mockito is an open source testing framework for Java released under the MIT License.[3][4] The framework allows the creation of test double objects (mock objects) in automated unit tests for the purpose of test-driven development (TDD) or behavior-driven development (BDD)."
Mockito allows us to create and control fake, or "mock", objects and methods to adjust the execution environment for the units we are testing.
For instance, if we want to test a function that consumes a heavyweight class with many prerequisites, but which only uses one piece of information from each instance of that class, we can create a mock instance of that class and explicitly set the piece of information that our unit under test will consume.
Consider Compute.java:getAffaDamageFor(Entity entity)
, which computes the accidental fall damage an entity will suffer by getting the Entity's weight via entity.getWeight()
. To test this function without instantiating an Entity, which is a large and complex Class, we could instead create a mock Entity that contains only a getWeight()
function, and then call getAffaDamageFor()
against that mock Entity, like so:
Entity mockEntity = mock(Entity.class);
when(mockEntity.getWeight()).thenReturn(50.0);
assertEqual(5, Compute.getAffaDamageFor(mockEntity));
Note:
- the mock is created from the
Entity.class
definition, not from an instance of the class. - The variable is declared as if it actually contained an instance of that class, for type-checking purposes.
- all required members and methods used by the unit under test must be declared prior to calling the function with that mocked object.
(Also, in general it is not necessary to mock Entity or its subclasses, which can be instantiated very easily; see existing unit tests for details).
While some programmers consider mocks to be a "Code Smell" because they indicate unwieldy code that is difficult to unit test, mocks are still a powerful tool for unit testing, especially of large codebases such as MegaMek.
Some keywords and concepts from Mockito include:
-
mock(<class name>.class)
: a method to create a mock object based on the specified class type. -
mockObject.<member> = <value>
: set the member of the mock object to a specified value or object. -
when(<mock instance>.<realFunctionName>()).thenReturn(<concrete value>)
: specifies the return value if the named function is called on the mock object. -
spy(new ClassName())
: the inverse ofmock()
, this function allows controlling the internal working of a real class instance while leaving its normal functioning in place. Useful for configuring dependencies that are too complex to easily mock. -
any*()
: an argument matcher q.v that makeswhen()
declarations conditional, e.g.when(mockEntity.calculateBattleValue(anyBoolean(), anyBoolean())).thenReturn(1000);
would return the same value no matter what booleans were passed, as long as the function that accepts two booleans were called (but other signatures would not be defined).
-
eq(<concrete value>)
: argument matcher that expects an exact value.
Note: .thenReturns()
, any*()
, and eq()
must match expected types for return value and argument type!
Unit Tests follow the Java naming convention for functions: "Methods should be verbs, in mixed case with the first letter lowercase, with the first letter of each internal word capitalized." Test function names usually also start with the word 'test', although this is not required. The unit test's name should reflect what it is testing.
Tests take no arguments and return no value; if their assertions do not raise any exceptions, they are considered to pass. Unit tests should be as simple as possible, and do only the work necessary to effectively test the unit (function, method, member, or class) under test.
@Test
public void testGetCrewSizeForDropShipOfKnownWeight() {
Entity ds = new Dropship();
ds.setWeight(10000.0);
assertEquals(5, ds.getCrew().getSize());
}
For instance, the above unit test describes what it tests, uses a real Entity (in this case an instance of DropShip), and tests exactly one call. This is a fine, if minimal, test.
MegaMek and MekHQ already have extensive Unit Test coverage, so most likely you will be able to put new unit tests into existing suites without needing to add any setup or teardown functionality. But MML will almost certainly require creating Unit Test suites from scratch, and writing new Unit Tests for the other two projects will as well, if working in areas that lack test coverage right now.
The Idea IDE provides a simple and reliable test suite generation method via its context menu, and I highly recommend using that to create any new test suites. Another method is copying and renaming an existing suite within the ../unittests/
directory structure, although this can be messy and leave superfluous imports or other unnecessary configuration in place.
A test suite file is a standard Java class that lives in ../unittests/
within a given project's source repo, and its name should be <Class under test> + Test.java
(or + Tests.java
). Ideally it should be placed in a directory structure that matches the original Java class's namespace, e.g. src/megamek/common/AmmoType.java
has a matching test suite at unittests/megamek/common/AmmoTypeTest.java
. Provided that the test suite is located in the /unittests/
directory, though, Gradle will detect it and run its contents when the "test" task is selected and started.
To generate a new test suite using the IDE, follow these steps:
- Open the Java class to be tested, right-click anywhere within its text to bring up the context menu, and select "Generate":
- From the Generate sub-menu, select "Test...":
- Within the "Create Test" options dialog, choose at least "Generate: setUp/@Before" and one or more functions of the Class under test ("tearDown/@After" is useful when creating on-disk artifacts during tests):
- Click "Ok", the IDE will display your new Unit Test Suite class with auto-generated imports, and the skeleton of your selected options already in place:
Note: many of our suites require @BeforeAll methods to properly properly initialize EquipmentType entries; the auto-generation method does not do this step.
While mocks are very useful for unit testing and can simplify test setup considerably, try to avoid using mocks unless absolutely necessary, for a couple reasons:
- it is preferable to test real code than mocked code from a coverage standpoint. Any time a real instance or constant or function can be used in place of a mock, we expand our test coverage.
- mocks can introduce unexpected failures, or hide real failures by aliasing functions or members that are broken. This is also the reason that, when mocks are used, they should define the bare minimum methods and members required for testing.
Spying is useful when we want to utilize a real class instance but control certain aspects of its behavior, or instrument it to get visibility into private functionality without changing the access controls already set within its code. Spying also allows setting specific preconditions for a test that might otherwise be difficult to arrange, for instance, if a dependency of our unit under test utilizes random generation but we want to test a specific configuration.
There are some caveats, however; Java 11 and Java 17 have different compatibility and requirements for spying, so some tests that use spy()
may work in one version but not in another. Switching to a mock, or using dependency injection instead (see the entry below) should then be considered.
Unit testing is easiest to accomplish when the units under test - functions, class methods, or classes themselves - are simple and do not create a large number of dependencies internally. That is, code is easier to unit test when it is relatively lightweight and we can directly control what goes into it. This means that class methods that rely on private static
object references can be much more work to unit test, while public static void
functions that take a single argument are relatively simple to write unit tests for.
One can write code that is easier to unit test by avoiding using private member values; by using the fewest possible arguments; by writing small, single-purpose functions; and by writing unit tests while writing the units that they test. The natural extension of this practice is called Test-Driven Development, which involves writing unit tests first, then implementing the code that will satisfy those unit tests.
However, the trade-offs between clean code, performant code, compact code, and readable code will often make unit testing more difficult. This is acceptable as long as unit testing of the new code is still possible.
Dependency Injection is the practice of supplying heavyweight dependencies to functions as arguments rather than as private instance members or on-the-fly generated variables to improve unit test compatibility.
Objects such as:
- network connections
- large game objects
- Collections of objects
- etc.
are simpler to pass in to a function than to build as members of a mock object. If the unit under test is a class method, it may be useful to make all the private class members it access into arguments to that class, but in some cases (a public class called from other parts of the codebase, say) this may not be possible.
It is acceptable, and in many cases desirable, to refactor existing code to make it more amenable to unit testing iff unit tests are also being developed. Don't worry about refactoring code that already has full unit test coverage, or if you yourself will not implement unit tests for the newly-refactored code.
Often we will encounter monolithic code blocks encapsulated in single functions that consume dozens of arguments; these are extremely difficult to unit test so they are often both completely without coverage, and most in need of unit testing.
In such cases one efficient approach is to extract the contents of a large if/else
block into a new function, and write unit tests just for that function.
This has several benefits:
- You will have to identify which variables within the monolithic function are required by the extracted code and which can be ignored. This should be a subset of the variables and arguments to that larger function, simplifying the unit testing requirements.
- You may be able to abstract out useful functionality that can be reused elsewhere in this function or the codebase in general, reducing bloat and increasing efficiency.
- Writing unit tests for this new function may reveal helper functions that will allow unit testing other parts of the parent function, which will increase test coverage.
and so on.
(WIP)
(WIP)
(WIP)
Below are a few examples of hypothetical, or actual, unit tests.
@Test
public void testCastStringTrueToBooleanIsTrue() {
assertTrue(Boolean.valueOf("True"));
}
(WIP)