Previous: CompletableFuture for Asynchronous Operations Next: Practical Exercises

Module 6: Reflection API for Dynamic Test Frameworks

Overview

The Java Reflection API provides the ability to inspect and manipulate classes, methods, fields, and other components at runtime. In test automation, reflection is particularly powerful for creating dynamic test frameworks, data-driven testing, and building flexible test utilities that can adapt to different scenarios without code changes.

Learning Objectives

By the end of this module, you will be able to:

  1. Understand the fundamentals of Java Reflection API
  2. Dynamically inspect and manipulate classes and objects
  3. Create flexible test frameworks using reflection
  4. Implement data-driven testing with reflection
  5. Build annotation-based test utilities
  6. Handle reflection exceptions and performance considerations

6.1 Introduction to Reflection

What is Reflection?

Reflection is the ability of a program to examine and modify its own structure and behavior at runtime. It allows you to:

Basic Reflection Example

@Test
public void testBasicReflection() throws Exception {
    // Get class information
    Class<String> stringClass = String.class;
    
    // Get class name
    assertEquals("java.lang.String", stringClass.getName());
    
    // Get methods
    Method[] methods = stringClass.getMethods();
    assertTrue(methods.length > 0);
    
    // Find specific method
    Method lengthMethod = stringClass.getMethod("length");
    assertNotNull(lengthMethod);
    
    // Invoke method
    String testString = "Hello World";
    int length = (Integer) lengthMethod.invoke(testString);
    assertEquals(11, length);
}

6.2 Working with Classes

Obtaining Class Objects

@Test
public void testObtainingClasses() throws Exception {
    // Method 1: Using .class literal
    Class<String> stringClass1 = String.class;
    
    // Method 2: Using Class.forName()
    Class<?> stringClass2 = Class.forName("java.lang.String");
    
    // Method 3: Using getClass() on instance
    String str = "test";
    Class<?> stringClass3 = str.getClass();
    
    // All should be the same
    assertEquals(stringClass1, stringClass2);
    assertEquals(stringClass2, stringClass3);
}

Inspecting Class Information

public class TestClassInspector {
    
    @Test
    public void testClassInspection() {
        Class<ArrayList> listClass = ArrayList.class;
        
        // Get package information
        Package pkg = listClass.getPackage();
        assertEquals("java.util", pkg.getName());
        
        // Get superclass
        Class<?> superClass = listClass.getSuperclass();
        assertEquals("java.util.AbstractList", superClass.getName());
        
        // Get interfaces
        Class<?>[] interfaces = listClass.getInterfaces();
        assertTrue(interfaces.length > 0);
        
        // Check if it implements specific interface
        assertTrue(List.class.isAssignableFrom(listClass));
    }
    
    @Test
    public void testModifiers() {
        Class<String> stringClass = String.class;
        
        int modifiers = stringClass.getModifiers();
        
        assertTrue(Modifier.isPublic(modifiers));
        assertTrue(Modifier.isFinal(modifiers));
        assertFalse(Modifier.isAbstract(modifiers));
    }
}

6.3 Working with Fields

Accessing and Modifying Fields

public class TestUser {
    private String name;
    public int age;
    private static String defaultRole = "user";
    
    public TestUser(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    // Getters and setters...
}

@Test
public void testFieldAccess() throws Exception {
    TestUser user = new TestUser("John", 25);
    Class<TestUser> userClass = TestUser.class;
    
    // Access public field
    Field ageField = userClass.getField("age");
    assertEquals(25, ageField.get(user));
    
    // Modify public field
    ageField.set(user, 30);
    assertEquals(30, ageField.get(user));
    
    // Access private field
    Field nameField = userClass.getDeclaredField("name");
    nameField.setAccessible(true); // Make accessible
    assertEquals("John", nameField.get(user));
    
    // Modify private field
    nameField.set(user, "Jane");
    assertEquals("Jane", nameField.get(user));
}

@Test
public void testStaticFieldAccess() throws Exception {
    Class<TestUser> userClass = TestUser.class;
    
    // Access static field
    Field roleField = userClass.getDeclaredField("defaultRole");
    roleField.setAccessible(true);
    assertEquals("user", roleField.get(null)); // null for static fields
    
    // Modify static field
    roleField.set(null, "admin");
    assertEquals("admin", roleField.get(null));
}

6.4 Working with Methods

Invoking Methods Dynamically

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
    
    public double multiply(double a, double b) {
        return a * b;
    }
    
    private String formatResult(double result) {
        return String.format("%.2f", result);
    }
}

@Test
public void testMethodInvocation() throws Exception {
    Calculator calc = new Calculator();
    Class<Calculator> calcClass = Calculator.class;
    
    // Invoke public method
    Method addMethod = calcClass.getMethod("add", int.class, int.class);
    int result = (Integer) addMethod.invoke(calc, 5, 3);
    assertEquals(8, result);
    
    // Invoke method with different parameter types
    Method multiplyMethod = calcClass.getMethod("multiply", double.class, double.class);
    double multiplyResult = (Double) multiplyMethod.invoke(calc, 2.5, 4.0);
    assertEquals(10.0, multiplyResult, 0.001);
    
    // Invoke private method
    Method formatMethod = calcClass.getDeclaredMethod("formatResult", double.class);
    formatMethod.setAccessible(true);
    String formatted = (String) formatMethod.invoke(calc, 10.567);
    assertEquals("10.57", formatted);
}

6.5 Working with Constructors

Dynamic Object Creation

@Test
public void testConstructorInvocation() throws Exception {
    Class<TestUser> userClass = TestUser.class;
    
    // Get constructor
    Constructor<TestUser> constructor = userClass.getConstructor(String.class, int.class);
    
    // Create instance
    TestUser user = constructor.newInstance("Alice", 28);
    assertNotNull(user);
    
    // Verify the object was created correctly
    Field nameField = userClass.getDeclaredField("name");
    nameField.setAccessible(true);
    assertEquals("Alice", nameField.get(user));
    
    Field ageField = userClass.getField("age");
    assertEquals(28, ageField.get(user));
}

@Test
public void testDefaultConstructor() throws Exception {
    Class<ArrayList> listClass = ArrayList.class;
    
    // Create instance using default constructor
    Constructor<ArrayList> constructor = listClass.getConstructor();
    ArrayList<String> list = constructor.newInstance();
    
    assertNotNull(list);
    assertTrue(list.isEmpty());
}

6.6 Working with Annotations

Creating Custom Annotations

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface TestInfo {
    String description() default "";
    String author() default "";
    int priority() default 1;
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DataProvider {
    String value() default "";
}

public class AnnotatedTestClass {
    
    @TestInfo(description = "Tests user login functionality", author = "John", priority = 1)
    @Test
    public void testLogin() {
        // Test implementation
    }
    
    @TestInfo(description = "Tests user registration", author = "Jane", priority = 2)
    @Test
    public void testRegistration() {
        // Test implementation
    }
    
    @DataProvider("loginData")
    public Object[][] getLoginData() {
        return new Object[][]{
            {"user1", "pass1"},
            {"user2", "pass2"}
        };
    }
}

Processing Annotations

@Test
public void testAnnotationProcessing() throws Exception {
    Class<AnnotatedTestClass> testClass = AnnotatedTestClass.class;
    
    // Get all methods
    Method[] methods = testClass.getDeclaredMethods();
    
    for (Method method : methods) {
        // Check if method has TestInfo annotation
        if (method.isAnnotationPresent(TestInfo.class)) {
            TestInfo testInfo = method.getAnnotation(TestInfo.class);
            
            System.out.println("Method: " + method.getName());
            System.out.println("Description: " + testInfo.description());
            System.out.println("Author: " + testInfo.author());
            System.out.println("Priority: " + testInfo.priority());
        }
        
        // Check for DataProvider annotation
        if (method.isAnnotationPresent(DataProvider.class)) {
            DataProvider dataProvider = method.getAnnotation(DataProvider.class);
            System.out.println("Data Provider: " + dataProvider.value());
        }
    }
}

6.7 Building Dynamic Test Framework

Test Runner with Reflection

public class ReflectionTestRunner {
    
    public void runTests(Class<?> testClass) throws Exception {
        Object testInstance = testClass.newInstance();
        Method[] methods = testClass.getDeclaredMethods();
        
        // Run setup methods first
        runMethodsWithAnnotation(testInstance, methods, "BeforeEach");
        
        // Run test methods
        for (Method method : methods) {
            if (method.isAnnotationPresent(Test.class)) {
                runSingleTest(testInstance, method);
            }
        }
        
        // Run cleanup methods
        runMethodsWithAnnotation(testInstance, methods, "AfterEach");
    }
    
    private void runSingleTest(Object testInstance, Method testMethod) {
        try {
            System.out.println("Running test: " + testMethod.getName());
            
            // Check for TestInfo annotation
            if (testMethod.isAnnotationPresent(TestInfo.class)) {
                TestInfo info = testMethod.getAnnotation(TestInfo.class);
                System.out.println("Priority: " + info.priority());
                System.out.println("Description: " + info.description());
            }
            
            testMethod.invoke(testInstance);
            System.out.println("✓ Test passed: " + testMethod.getName());
            
        } catch (Exception e) {
            System.err.println("✗ Test failed: " + testMethod.getName());
            System.err.println("Error: " + e.getCause().getMessage());
        }
    }
    
    private void runMethodsWithAnnotation(Object testInstance, Method[] methods, 
                                        String annotationName) throws Exception {
        for (Method method : methods) {
            Annotation[] annotations = method.getAnnotations();
            for (Annotation annotation : annotations) {
                if (annotation.annotationType().getSimpleName().equals(annotationName)) {
                    method.invoke(testInstance);
                }
            }
        }
    }
}

6.8 Data-Driven Testing with Reflection

Dynamic Data Provider

public class DataDrivenTestRunner {
    
    public void runDataDrivenTest(Class<?> testClass, String testMethodName) 
            throws Exception {
        Object testInstance = testClass.newInstance();
        Method testMethod = testClass.getMethod(testMethodName, String.class, String.class);
        
        // Find data provider method
        Method dataProviderMethod = findDataProviderMethod(testClass, testMethodName);
        
        if (dataProviderMethod != null) {
            Object[][] testData = (Object[][]) dataProviderMethod.invoke(testInstance);
            
            for (int i = 0; i < testData.length; i++) {
                Object[] row = testData[i];
                System.out.println("Running test with data set " + (i + 1));
                
                try {
                    testMethod.invoke(testInstance, row);
                    System.out.println("✓ Test passed with data: " + Arrays.toString(row));
                } catch (Exception e) {
                    System.err.println("✗ Test failed with data: " + Arrays.toString(row));
                    System.err.println("Error: " + e.getCause().getMessage());
                }
            }
        }
    }
    
    private Method findDataProviderMethod(Class<?> testClass, String testMethodName) {
        Method[] methods = testClass.getDeclaredMethods();
        
        for (Method method : methods) {
            if (method.isAnnotationPresent(DataProvider.class)) {
                DataProvider annotation = method.getAnnotation(DataProvider.class);
                if (annotation.value().equals(testMethodName + "Data")) {
                    return method;
                }
            }
        }
        return null;
    }
}

6.9 Performance and Best Practices

Caching Reflection Objects

public class ReflectionCache {
    private static final Map<String, Method> methodCache = new ConcurrentHashMap<>();
    private static final Map<String, Field> fieldCache = new ConcurrentHashMap<>();
    
    public static Method getCachedMethod(Class<?> clazz, String methodName, 
                                       Class<?>... parameterTypes) throws NoSuchMethodException {
        String key = clazz.getName() + "." + methodName + "(" + 
                    Arrays.toString(parameterTypes) + ")";
        
        return methodCache.computeIfAbsent(key, k -> {
            try {
                return clazz.getMethod(methodName, parameterTypes);
            } catch (NoSuchMethodException e) {
                throw new RuntimeException(e);
            }
        });
    }
    
    public static Field getCachedField(Class<?> clazz, String fieldName) 
            throws NoSuchFieldException {
        String key = clazz.getName() + "." + fieldName;
        
        return fieldCache.computeIfAbsent(key, k -> {
            try {
                Field field = clazz.getDeclaredField(fieldName);
                field.setAccessible(true);
                return field;
            } catch (NoSuchFieldException e) {
                throw new RuntimeException(e);
            }
        });
    }
}

Exception Handling

public class SafeReflectionUtils {
    
    public static Optional<Object> invokeMethodSafely(Object instance, 
                                                    String methodName, 
                                                    Object... args) {
        try {
            Class<?> clazz = instance.getClass();
            Class<?>[] paramTypes = Arrays.stream(args)
                .map(Object::getClass)
                .toArray(Class[]::new);
            
            Method method = clazz.getMethod(methodName, paramTypes);
            Object result = method.invoke(instance, args);
            return Optional.ofNullable(result);
            
        } catch (Exception e) {
            System.err.println("Failed to invoke method: " + methodName);
            System.err.println("Error: " + e.getMessage());
            return Optional.empty();
        }
    }
    
    public static Optional<Object> getFieldValueSafely(Object instance, 
                                                      String fieldName) {
        try {
            Field field = instance.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            Object value = field.get(instance);
            return Optional.ofNullable(value);
            
        } catch (Exception e) {
            System.err.println("Failed to get field value: " + fieldName);
            System.err.println("Error: " + e.getMessage());
            return Optional.empty();
        }
    }
}
Previous: CompletableFuture for Asynchronous Operations Next: Practical Exercises