Skip to content

A test kit to create fluent assertions for the ASM Java byte code modification framework, built on top of AssertJ.

License

Notifications You must be signed in to change notification settings

marcelkliemannel/asm-testkit

Repository files navigation

ASM Test Kit

This library is a test kit to create fluent assertions for the ASM Java byte code modification framework, built on top of AssertJ.

⚠️ This library is still under development. Although the API is already very comprehensive, smaller parts are still missing (e.g., the support for ModuleNodes). And till the library does not reach its first major release, there may be minor API breaking changes.

Highlights

Comparing Byte Code

ASM is a great framework to create and modify Java byte code. However, we face the challenge that errors in the byte code generation only become visible at runtime in the JVM. Therefore, good test coverage of the generated code is essential.

This library supports us in writing unit tests to prove that our modified byte code equals the one the Java compiler would generate from the source code.

Let's look at the capabilities of this test kit with an example. Suppose we want to generate the following simple method:

static void sayHello() {
  System.out.println("Hello World");
}

The corresponding ASM logic would look like this:

private void generateSayHelloMethod(ClassNode classToModify) {
  MethodNode sayHello = new MethodNode(Opcodes.ACC_STATIC, "sayHello", "()V", null, null);

  var instructions = new InsnList();
  instructions.add(new FieldInsnNode(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"));
  instructions.add(new LdcInsnNode("Hello World"));
  instructions.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V"));
  instructions.add(new InsnNode(Opcodes.RETURN));
  sayHello.instructions = instructions;

  classToModify.methods.add(sayHello);
}

Next, we want to write a unit test for this logic.

For such a test, we first need a class file, which we can then modify using ASM. We can specify this as a String directly in our test (line 3 in the following code). We then compile this Java source (line 8) and apply our ASM logic to the resulting class file (line 10). Finally, we reread the class file into the ClassNode, containing our added method.

@Test
void testFieldGeneration() {
  String actualSource = "class MyClass {}";
  
  ClassNode actual = CompilationEnvironment
            .create()
            .addJavaInputSource(actualSource)
            .compile();
            // Reads class file and writes the modification back
            .modifyClassNode("MyClass", this::generateSayHelloMethod)
            // Reads modified class file
            .readClassNode("MyClass");
  ...
}

In the next step, we want to compare the byte code we generated with the one the Java compiler would create. Therefore, we also define a String that contains the Java source code of the method as if we were programming it in an IDE. Then we do the same as before, we compile this class and read the ClassNode from the class file:

String expectedSource = "class MyClass {" +
                         "  static void sayHello() { " +
                         "    System.out.println(\"Hello World\"); " +
                         "  }" +
                         "}";

ClassNode expected = CompilationEnvironment
          .create()
          .addJavaInputSource(expectedSource)
          .compile()
          .readClassNode("MyClass");

Finally, we have the actual ClassNode, which represents the byte code generated by our ASM logic, and the expected ClassNode, as the Java compiler would generate it. So we now only have to make sure that they are equal by using an ASM assertion from this library:

 AsmAssertions.assertThat(actual)
              .ignoreLineNumbers()
              .isEqualTo(expected);

Since the output of the ASM assertions is based on the AssertJ output format, there is a welcome side effect: IDEs like IntelliJ offer us a link in the console output to open a diff window, which highlights the non-matching parts in the byte code.

Making Byte Code Readable

Sometimes it is helpful to display the components of a class file in readable form for debugging. For this purpose, this library provides a set of AssertJ Representation classes that we can use to get a textualized form of an ASM node.

For example, we can get a String representation of a class with the following call:

ClassNodeRepresentation.INSTANCE.toStringOf(actual)

And the output could look like this:

// Class version: 55
[32: super] class MyClass extends java.lang.Object

    [0] <init>()
        L0
          LINENUMBER 1 L0
          ALOAD 0 // opcode: 25
          INVOKESPECIAL java/lang/Object.<init> ()V // opcode: 183
          RETURN // opcode: 177
        L1
      // Local variable: #0 MyClass this // range: L0-L1
      // Max locals: 1
      // Max stack: 1

    [8: static] void sayHello()
          GETSTATIC java/lang/System.out : Ljava/io/PrintStream; // opcode: 178
          LDC "Hello World" // opcode: 18
          INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V // opcode: 182
          RETURN // opcode: 177
      // Max locals: 0
      // Max stack: 2

  // Source file: MyClass.java

Usage

This library is available at Maven Central:

Gradle

// Groovy
implementation 'dev.turingcomplete:asm-testkit:0.1.0'

// Kotlin
implementation("dev.turingcomplete:asm-testkit:0.1.0")

Maven

<dependency>
    <groupId>dev.turingcomplete</groupId>
    <artifactId>asm-testkit</artifactId>
    <version>0.1.0</version>
</dependency>

Assertions

The factory class AsmAssertions is the main entry to create AssertJ assertions for ASM nodes:

assertThat(AccessNode actual)
assertThat(Attribute actual)
assertThat(AnnotationNode actual)
assertThat(TypeAnnotationNode actual)
assertThat(LocalVariableAnnotationNode actual)
assertThat(TypePath actual)
assertThat(Type actual)
assertThat(FieldNode actual)
assertThat(TypeReference actual)
assertThat(AbstractInsnNode actual)
assertThat(InsnList actual)
assertThat(LabelNode actual)
assertThat(LocalVariableNode actual)
assertThat(TryCatchBlockNode actual)
assertThat(ParameterNode actual)
assertThat(AnnotationDefaultNode actual)
assertThat(MethodNode actual)
assertThat(InnerClassNode actual)
assertThat(ClassNode actual)

An ASM node assert inherits from AbstractAssert and has the same capabilities as typical AssertJ assertions, for example:

AsmAssertions.assertThat(classNodeA)
             .isEqualTo(classNodeB);

Furthermore, there are additional factory methods to create assertions for an Iterable of ASM nodes:

assertThatAttributes(Iterable<Attribute> actual)
assertThatAnnotations(Iterable<AnnotationNode> actual)
assertThatTypeAnnotations(Iterable<TypeAnnotationNode> actual)
assertThatLocalVariableAnnotations(Iterable<LocalVariableAnnotationNode> actual)
assertThatTypePaths(Iterable<TypePath> actual)
assertThatTypes(Iterable<Type> actual)
assertThatTypeReferences(Iterable<TypeReference> actual)
assertThatInstructions(Iterable<AbstractInsnNode> actual)
assertThatFields(Iterable<FieldNode> actual)
assertThatLabels(Iterable<LabelNode> actual)
assertThatLocalVariables(Iterable<LocalVariableNode> actual)
assertThatTryCatchBlocks(Iterable<TryCatchBlockNode> actual)
assertThatParameters(Iterable<ParameterNode> actual)
assertThatAnnotationDefaulls(Iterable<AnnotationDefaultNode> actual)
assertThatAccesses(Iterable<AccessNode> actual)
assertThatMethods(Iterable<MethodNode> actual)
assertThatInnerClasses(Iterable<InnerClassNode> actual)
assertThatClasses(Iterable<ClassNode> actual)

These assertions inherit from AbstractIterableAssert and have a wide range of capabilities to check the characteristics of a collection.

However, you should note that these assertions are preferable to be used with containsExactlyInAnyOrderElementsOf or containsExactlyInAnyOrderCompareOneByOneElementsOf. An exception to this is assertThatInstructions, here isEqual should be used (because the order is essential). All other AssertJ assertions methods should work but may not utilize the full functionality of the ASM test kit. If you need them, feel free to create an issue.

Comparators

Analogous to the assertions, there are java.util.Comparators for ASM nodes, which we can use to define an order, or at least use it to check the equality of two nodes. They are in the package dev.turingcomplete.asmtestkit.comparator and are mainly used as a backbone for the ASM related AbstractIterableAsserts.

All ASM comparators have at least two constant fields INSTANCE and INSTANCE_ITERABLE, which provide a reusable instance for a single ASM node or a Iterable of nodes.

The mother of all comparators is the DefaultAsmComparators. This class bundles multiple comparator instances and can return a specific instance for a given ASM class:

Comparator<MethodNode> methodNodeComparator = DefaultAsmComparators.INSTANCE.elementComparator(MethodNode.clsss);

The purpose behind this overclass is the hierarchy of operators. For example, a comparator for ClassNodes uses the comparator for MethodNodes, which uses the comparator for InsnList.

Representations

Readable textual representation of ASM nodes can be created using the AssertJ org.assertj.core.presentation.Representations from the package dev.turingcomplete.asmtestkit.representation.

The primary representation method is #toStringOf(Object), which creates a complete representation of an ASM node. In addition, some support the #toSimplifiedStringOf(Object) functionality, which we can use to produce a short, single-line output. For example, for a MethodNode, only the method header but not the body will be output.

All ASM representations have a constant field INSTANCE with a reusable instance.

There is also an overclass with DefaultAsmRepresentations that bundles all representations, which functions similarly to the one of for the comparators:

AsmRepresentation<MethodNode> methodNodeRepresentation =  DefaultAsmRepresentations.INSTANCE.getAsmRepresentation(MethodNode.class);

This class is also a Representation and can therefore be used as a single entry for any ASM node:

DefaultAsmRepresentations.INSTANCE.toStringOf(methodNode);
DefaultAsmRepresentations.INSTANCE.toStringOf(fieldNode);
...

Ignoring Line Numbers

The assertions and comparators to check ClassNodess, MethodNodess and InsnLists can ignore LineNumberNodes and their associated LabelNodes, by calling the method ignoreLineNumbers().

Open Todos

  • Implement assertions, comparators and representations for:
    • ModuleNode
    • ModuleRequireNode
    • ModuleExportNode
    • ModuleOpenNode
    • ModuleProvideNode
    • RecordNode
  • Some of the AsmAsserts don't make use of StandardAssertOption.IGNORE_* yet
  • Add AssertOptions to ignore some FieldNodes and MethodNodes.

Licensing

Copyright (c) 2022 Marcel Kliemannel

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 https://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.

About

A test kit to create fluent assertions for the ASM Java byte code modification framework, built on top of AssertJ.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published