Skip to content

TestPlan Expansion

How FHIR Frog expands TestPlans into executable JUnit tests.

Expansion Process

TestPlans are dynamically expanded at runtime:

  1. Load TestPlan - Read from server or classpath
  2. Resolve references - Load referenced TestScripts/TestPlans
  3. Build dependency graph - Analyze dependencies
  4. Detect cycles - Fail if circular dependencies found
  5. Topological sort - Determine execution order
  6. Generate JUnit tests - Create dynamic test methods
  7. Execute - Run tests in order

Simple Expansion

TestPlan

{
  "resourceType": "TestPlan",
  "id": "patient-suite",
  "testCase": [
    {
      "testRun": [
        {"script": {"reference": "TestScript/patient-create"}},
        {"script": {"reference": "TestScript/patient-read"}},
        {"script": {"reference": "TestScript/patient-delete"}}
      ]
    }
  ]
}

Expanded JUnit Tests

@TestPlan("TestPlan/patient-suite")
class PatientSuite {

    @Test
    @DisplayName("patient-create")
    void testPatientCreate() {
        // Execute TestScript/patient-create
    }

    @Test
    @DisplayName("patient-read")
    void testPatientRead() {
        // Execute TestScript/patient-read
    }

    @Test
    @DisplayName("patient-delete")
    void testPatientDelete() {
        // Execute TestScript/patient-delete
    }
}

Recursive Expansion

Nested TestPlans

{
  "resourceType": "TestPlan",
  "id": "au-core-suite",
  "testCase": [
    {
      "testRun": [
        {"script": {"reference": "TestPlan/patient-suite"}},
        {"script": {"reference": "TestPlan/practitioner-suite"}}
      ]
    }
  ]
}

Expanded Structure

AUCoreSuite
├── PatientSuite
│   ├── patient-create
│   ├── patient-read
│   └── patient-delete
└── PractitionerSuite
    ├── practitioner-create
    ├── practitioner-read
    └── practitioner-delete

Dependency Expansion

With Dependencies

{
  "testCase": [
    {
      "sequence": 1,
      "testRun": [{"script": {"reference": "TestScript/setup"}}]
    },
    {
      "sequence": 2,
      "dependency": [{"predecessor": "TestScript/setup"}],
      "testRun": [
        {"script": {"reference": "TestScript/test1"}},
        {"script": {"reference": "TestScript/test2"}}
      ]
    }
  ]
}

Expanded with Order

@TestPlan("TestPlan/with-deps")
class WithDependencies {

    @Test
    @Order(1)
    @DisplayName("setup")
    void testSetup() {}

    @Test
    @Order(2)
    @DisplayName("test1")
    void testTest1() {}

    @Test
    @Order(2)  // Same order - can run in parallel
    @DisplayName("test2")
    void testTest2() {}
}

Expansion Diagram

TestPlan Expansion

Cycle Detection

Detection Algorithm

public void detectCycles(TestPlan plan) {
    Set<String> visiting = new HashSet<>();
    Set<String> visited = new HashSet<>();

    for (TestCase testCase : plan.getTestCase()) {
        if (hasCycle(testCase, visiting, visited)) {
            throw new CycleDetectedException();
        }
    }
}

Example Cycle

TestPlan A
  └─> TestPlan B
       └─> TestPlan C
            └─> TestPlan A  ❌ Cycle!

Error:

Circular dependency detected:
  TestPlan/a -> TestPlan/b -> TestPlan/c -> TestPlan/a

Execution Order

Topological Sort

Input Dependencies:
  A: []
  B: [A]
  C: [A]
  D: [B, C]

Topological Sort:
  Level 1: A
  Level 2: B, C (parallel)
  Level 3: D

JUnit Execution

@TestPlan("TestPlan/sorted")
class SortedTests {

    @Test
    @Order(1)
    void testA() {}

    @Test
    @Order(2)
    void testB() {}

    @Test
    @Order(2)  // Parallel with testB
    void testC() {}

    @Test
    @Order(3)
    void testD() {}
}

Dynamic Test Generation

JUnit 5 TestFactory

@TestFactory
Stream<DynamicTest> expandTestPlan() {
    TestPlan plan = loadTestPlan("TestPlan/suite");
    List<TestScript> scripts = expand(plan);

    return scripts.stream()
        .map(script -> DynamicTest.dynamicTest(
            script.getName(),
            () -> executeTestScript(script)
        ));
}

Nested Containers

@TestFactory
Stream<DynamicContainer> expandNestedTestPlan() {
    TestPlan plan = loadTestPlan("TestPlan/nested");

    return plan.getTestCase().stream()
        .map(testCase -> {
            List<DynamicTest> tests = expandTestCase(testCase);
            return DynamicContainer.dynamicContainer(
                testCase.getNarrative(),
                tests.stream()
            );
        });
}

Expansion Limits

Maximum Depth

Prevent infinite recursion:

private static final int MAX_DEPTH = 10;

public List<TestScript> expand(TestPlan plan, int depth) {
    if (depth > MAX_DEPTH) {
        throw new MaxDepthExceededException();
    }
    // Expand...
}

Maximum Tests

Limit total test count:

private static final int MAX_TESTS = 1000;

public List<TestScript> expand(TestPlan plan) {
    List<TestScript> scripts = new ArrayList<>();
    expandRecursive(plan, scripts);

    if (scripts.size() > MAX_TESTS) {
        throw new TooManyTestsException();
    }
    return scripts;
}

Performance

Caching

Cache expanded TestPlans:

private Map<String, List<TestScript>> cache = new HashMap<>();

public List<TestScript> expand(TestPlan plan) {
    String key = plan.getId();
    if (cache.containsKey(key)) {
        return cache.get(key);
    }

    List<TestScript> scripts = doExpand(plan);
    cache.put(key, scripts);
    return scripts;
}

Parallel Expansion

Expand independent branches in parallel:

public List<TestScript> expandParallel(TestPlan plan) {
    return plan.getTestCase().parallelStream()
        .flatMap(testCase -> expandTestCase(testCase).stream())
        .collect(Collectors.toList());
}

Best Practices

Keep It Simple

Avoid deep nesting. Aim for 2-3 levels maximum.

Watch Performance

Large TestPlans with many nested references can be slow to expand.

  1. Limit nesting depth - Max 2-3 levels
  2. Cache expansions - Reuse expanded plans
  3. Test expansion - Verify no cycles before production
  4. Monitor performance - Track expansion time
  5. Document structure - Explain TestPlan hierarchy

Next Steps