How it works - williamniemiec/ExecutionFlow GitHub Wiki
In this section we present the implementation of ExecutionFlow. In our implementation, we use, respectively, AspectJ, JDB, and JUnit. Next we detail the main implementation decisions of ExecutionFlow.
We use the advices to perform most of the computation required to collect the test paths. This computation involves collecting information about the M/CUTs, adapting the code of the test methods and of the M/CUTs to allow the correct tracing, and controlling part of the path extraction process.
The first advice collects the following information for each M/CUT called from a given test:
- location of source and executable files
- method signature
- call site (the number of the line in the test method where the call to M/CUT appears)
A second advice performs the following actions when the execution reaches a test method:
-
we need to modify the original test class to ensure the advices are executed over the correct test method. For the first advice to be processed and M/CUTs can be identified in the test method, one must run the testing framework for a given test class. Then, the information collected is used by a second advice to prepare this same test code (before the actual execution of the test) for the test path extraction. This means the testing framework has to be called a second time for the second advice to be executed and we must ensure that the second call to the testing framework process the same test method. Thus, after collecting the information about the M/CUTS, the first advice comments out all other test methods in that class, leaving only the current test method available for execution, and then calls the testing framework again.
-
one needs to ensure that all arguments in method calls are on the same line, to allow the correct identification of the executed path in terms of line numbers in a later step of the process (Section Test path extraction using a Debugger).
-
preparation of each M/CUT for the path extraction step that will be executed later. During compilation the basic structure of the code changes and some lines and/or structures may be omitted in the compiled code. For example, the if-then-else structure will be converted to if's and goto's. There is no else statement. Consequently, the line containing the
else
will be omitted. The same applies to the statements liketry-catch-finally
,continue
,break
,do-while
,switch
and declarations of uninitialized variables. The test path will be obtained using the compiled code, but it must be relatable to the original source code. Thus, we must adjust the original code by adding dummy instructions so as to keep the original behavior as well as the number of the lines associated to each statement that represents a node in the CFG. By doing this we make sure that the code executed in the path extraction phase (Section Test path extraction using a Debugger) corresponds exactly to the code seen by the developer. The added instructions can be any one that does not change the code behavior and is not suppressed by the compiler. -
compilation of the files with the M/CUTs instrumented as described above.
-
execution of the test method and extraction of the test paths using a debugger, as explained in Section Test path extraction using a Debugger.
The last action defined as an advice for when a test method is executed is responsible for the actual test path extraction and this is performed through the use of a debugger. Debuggers are programs that allow us to follow the execution of another program one line at a time and inspect its state during this process. An useful functionality of a debugger is the possibility of stopping at certain points of the source code (called breakpoints) and, from that point on, take specific actions such as step into a sentence (to carefully inspect its code), step over a sentence (execute without inspecting its contents), or simply continue (resume execution until the next breakpoint). In our approach, a debugger has a fundamental role, as it is through it that the test path will be obtained. To extract the test paths, we add a breakpoint in each M/CUT called from a test method and, when the debugger passes through these breakpoints, we step into those methods and constructors and register the number of all lines that are executed. In more detail, the following actions are performed in this step of the process:
- Place a breakpoint on the call to the M/CUT identified in the test method. This breakpoint is placed on the line where the method or constructor is invoked.
- Run the debugger.
- Upon reaching the breakpoint, run the
Step into
command and, while in the method, run theStep over
command. For eachStep over
command, the line being executed is registered. When returning from the method, theContinue
command is run. If it reaches the same breakpoint again (method is being called in an iteration), repeat the same procedure. Otherwise, execution is finished and another test method can be processed.
The bulk of the implementation is the definition of the pointcuts (identification of test methods in the source files) and advices (codification of the actions that must be taken at each join point at runtime). In ExecutionFlow, we take advantage of JUnit notation and define a pointcut wherever there is the annotation @Test in the source files, as the example shown below.
public aspect MethodHook {
private pointcut insideTestedMethod():
&& withincode(@org.junit.Test * *.*())
&& !get(* *.*)
&& !set(* *.*);
before(): insideTestedMethod() {
identifyMethodSignature(thisJoinPoint);
findMethodFile(thisJoinPoint);
registerMethodInfo(thisJoinPoint);
}
[...]
}
The aspects are implemented in three classes, each one implementing the pointcuts and advices related to, respectively, application methods, constructors, and test methods. For M/CUTs, "before" advices are used to collect information that is later used to set up the debugger. For test methods, "around" advices are used to collect information before and after the test execution.
Through the advices we perform some adaptations in the test and application methods. In order to keep the test path information relatable to the original code, implementation-specific modifications are required, as detailed next.
First, we have to convert any JUnit5 tests to JUnit4 notation due to incompatibilities between AspectJ and the JUnit5 API. In addition, to automate the whole process, we have to ensure that the processing is not stopped even if a test fails. Although not recommended, in practice we find many test methods with multiple asserts. In this case, when one assert fails during execution, the execution of that test method is interrupted, precluding the extraction of the remaining test paths. One can deal with this situation in two ways: either assuming that all tests are successful, or making sure that the processing continues even in case of a failed assertion. To avoid limiting the usage scope of ExecutionFlow we choose to leave this decision to the user. Unless explicitly required by the user, the path corresponding to a failing assertion is not collected. When the assertion fails, an exception is thrown and the information collected so far is lost. When the user indicates otherwise (test paths of failing assertions must be kept), the advice includes try-catch structures around all assertions of the test method being processed, allowing the processing of the information collected so far (in the catch block).
As for the dummy instructions that must be added to the code as explained before, in this implementation we add a dummy assignment statement (like int d41d8c = 0
) that does not affect the system behavior. To avoid any conflict with identifiers of the original code, we use the MD5 encoding of the timestamp in the added statement.
After all modifications, the file is compiled and the debugger is executed over this modified file. Once the execution of the debugger is finished, the original files of the test and M/CUTs are restored.