How do I test components with injected dependencies?

← Dependency Injection · Ref: Q331

EK9 components are tested by creating test applications with mock/stub registrations. The abstract/concrete pattern makes substitution natural.

TEST APPLICATION PATTERN

Define a test application that registers test doubles instead of production components:

  defines application
    TestApp
      register MockLogger() as Logger
      register ServiceUnderTest() as Service

The ServiceUnderTest receives MockLogger through its injection field.

WHY NO @MOCKBEAN

Spring's @MockBean uses runtime reflection to replace beans in the application context. EK9 does not need this: you simply register a different concrete component against the same abstract type. The compiler validates the test wiring just as it validates production wiring.

TEST DOUBLES AS COMPONENTS

Create concrete components that implement the abstract contract with test behaviour:

  StubRepository is Repository
    override find()
      -> key as String
      <- result as String: "stub:" + key

This stub is a real component with the same contract as the production implementation.

ADVANTAGES OVER SPRING TESTING

1. NO CONTEXT LOADING: Spring tests often need @SpringBootTest which loads the entire context. EK9 test applications are lightweight.
2. COMPILE-TIME VALIDATION: Even test wiring is validated at compile time.
3. NO REFLECTION: Test doubles are concrete types, not proxy-based mocks.
4. EXPLICIT SUBSTITUTION: You can see exactly what's replaced in the test application.

See Q155 for writing unit tests. See Q227 for compile-time validation. See Q324 for injection basics. See Q234 for component lifecycle.

Example

defines module qa.di.testing.injected

  defines component

    <?-
      Abstract contract for the dependency.
    -?>
    EmailSender as abstract
      send() as abstract
        -> message as String
        <- status as String?

      default operator ?

    <?-
      Production implementation.
    -?>
    SmtpEmailSender is EmailSender
      override send()
        -> message as String
        <- status as String: "SMTP sent: " + message

      default operator ?

    <?-
      Test double: captures sent messages for verification.
    -?>
    StubEmailSender is EmailSender
      override send()
        -> message as String
        <- status as String: "STUB captured: " + message

      default operator ?

    NotificationService as abstract
      notifyUser() as abstract
        -> userId as String
        <- result as String?

      default operator ?

    DefaultNotificationService is NotificationService
      sender as EmailSender!

      override notifyUser()
        -> userId as String
        <- result <- String()
        result: sender.send("Notification for " + userId)

      default operator ?

  defines application

    <?-
      Production application: uses real email sender.
    -?>
    ProductionApp
      register SmtpEmailSender() as EmailSender
      register DefaultNotificationService() as NotificationService

    <?-
      Test application: substitutes stub email sender.
    -?>
    TestApp
      register StubEmailSender() as EmailSender
      register DefaultNotificationService() as NotificationService

  defines program

    ProductionDemo() with application of ProductionApp
      stdout <- Stdout()

      service as NotificationService!
      result <- service.notifyUser("user-42")
      stdout.println(result)

    TestDemo() with application of TestApp
      stdout <- Stdout()

      // === TEST APPLICATION SUBSTITUTES STUB FOR PRODUCTION ===

      service as NotificationService!
      result <- service.notifyUser("user-42")
      stdout.println(result)

      stdout.println("Test double injected via test application")

Common mistakes

E08150 — Injection fields must use abstract types. This is what makes test substitution possible: both ProductionApp and TestApp register different concrete types against the same abstract EmailSender. See ek9 -h E08150 for details.

Incorrect:

sender as SmtpEmailSender!

Correct:

sender as EmailSender!

E50001 — Renaming the variable means later references to 'result' become unresolved, triggering E50001. See ek9 -h E50001 for details.

Incorrect:

resultXYZ <- service.notifyUser("user-42")

Correct:

result <- service.notifyUser("user-42")
Other ways to ask this
  • How do I mock injected components in EK9?
  • What is the EK9 equivalent of @MockBean?
  • How do I unit test EK9 components with DI?

Coming from another language?

Java Spring: @MockBean/@SpyBean for test doubles, @SpringBootTest for integration tests, Mockito for mocking. Guice: override bindings in test modules. .NET: replace services in IServiceCollection for tests. Python: unittest.mock, dependency_injector overrides. Go: interface-based testing, manual mock construction. EK9: test applications with substitute component registrations, compile-time validated test wiring, no reflection-based mocking.

Keywords: substitute, migrate, component, unit, double, stub, test, mock, application, inject, verify