Skip to content

Testing

Analytics breaks quietly. A renamed parameter, or a double tap that sends an event twice, shows up weeks later as a wrong number on a dashboard. Herald's testing module lets you check it in tests. Add it to your tests:

testImplementation("io.github.mkhytarmkhoian:herald-testing:1.4.0")
testImplementation 'io.github.mkhytarmkhoian:herald-testing:1.4.0'

Its assertions throw a plain AssertionError, so any test framework works.

HeraldTesting is part of herald-ios. Add it to your test target only. Its assertions work in Swift Testing and XCTest alike: a failure is recorded at the line of the assertion, and the test goes on, as after a failed #expect.

flutter pub add dev:herald_testing

Its assertions throw a TestFailure, which package:test and flutter_test report like any failed expect.

npm install --save-dev @herald-analytics/testing

Its assertions throw an AnalyticsAssertionError, so they work with Jest or any other test runner.

The examples on this page are real tests, run on every Herald build.

FakeAnalyticsProvider

FakeAnalyticsProvider is a fake vendor that records every call instead of sending it. It implements all five interfaces, so you register it with a real Herald, and the test runs through the same setup you ship: your factories and your wrappers.

@Test
fun `checkout reports the plan and seats, once`() = runTest {
    val analytics = FakeAnalyticsProvider()
    val herald = Herald {
        provider(name = "test", events = analytics, properties = analytics)
        dispatcher(UnconfinedTestDispatcher(testScheduler))
    }

    CheckoutViewModel(analytics = herald).onCheckout(plan = "pro", seats = 3)

    analytics.assertTracked("checkout_started") {
        param("plan", "pro")
        param("seats", 3)      // an Int: the String "3" would fail here
    }
    analytics.assertNothingElseTracked()
}
@Test func checkoutReportsThePlanAndSeatsOnce() {
    let analytics = FakeAnalyticsProvider()
    let herald = Herald(providers: [
        HeraldProvider(name: "test", events: analytics, properties: analytics)
    ])

    CheckoutViewModel(analytics: herald).onCheckout(plan: "pro", seats: 3)

    analytics.assertTracked("checkout_started") { event in
        event.param("plan", "pro")
        event.param("seats", 3)  // an Int: the text "3" would fail here
    }
    analytics.assertNothingElseTracked()
}
test('checkout reports the plan and seats, once', () async {
  final analytics = FakeAnalyticsProvider();
  final herald = Herald(
    providers: [HeraldProvider(name: 'test', events: analytics, properties: analytics)],
  );

  await CheckoutViewModel(herald).onCheckout('pro', 3);

  analytics
    ..assertTracked(
      'checkout_started',
      (event) => event
        ..param('plan', 'pro')
        ..param('seats', 3), // an int: the String '3' would fail here
    )
    ..assertNothingElseTracked();
});
test('checkout reports the plan and seats, once', async () => {
  const analytics = new FakeAnalyticsProvider();
  const herald = new Herald({
    providers: [new HeraldProvider({ name: 'test', events: analytics, properties: analytics })],
  });

  await new CheckoutViewModel(herald).onCheckout('pro', 3);

  analytics
    .assertTracked(
      'checkout_started',
      (event) => event.param('plan', 'pro').param('seats', int(3)), // the text '3' would fail
    )
    .assertNothingElseTracked();
});
  • assertTracked expects exactly one match. A double tap that sends an event twice fails the test instead of passing quietly. Use assertTrackedTimes when a repeat is expected.
  • Parameters are checked by type. "3" sent as text fails against the number 3, because vendors receive the two differently.
  • assertNothingElseTracked() fails on any event no earlier assertion mentioned. That's what catches a duplicate, or an event sneaking in from another feature.
  • A failure prints everything that was recorded, which usually explains it:
Expected an event named 'checkout_pay_tapped', but it was never tracked.

Recorded:
  1. property plan = pro
  2. event    checkout_pay_pressed { plan = pro }

The other assertions are assertNotTracked, assertNothingTracked, assertPropertySet and assertIdentified.

Order between calls

records holds every call in order: events, properties, sign-in, consent and start-up. Use it when the order matters, such as a property that must be set before the event that should carry it:

@Test
fun `the purchase count is set before the purchase event that should carry it`() = runTest {
    val analytics = FakeAnalyticsProvider()
    val properties: PropertyTrackerService = analytics
    val events: EventTrackerService = analytics

    properties.set(PurchaseCount(1))
    events.track(PlanSelected("pro", seats = 1, price = 9.99, trial = false))

    val kinds = analytics.records.map { it::class }
    assertEquals(listOf(AnalyticsRecord.PropertySet::class, AnalyticsRecord.Tracked::class), kinds)
}
@Test func thePurchaseCountIsSetBeforeThePurchaseEventThatShouldCarryIt() {
    let analytics = FakeAnalyticsProvider()
    let properties: any PropertyTrackerService = analytics
    let events: any EventTrackerService = analytics

    properties.set(PurchaseCount(count: 1))
    events.track(PlanSelected(plan: "pro", seats: 1, price: 9.99, trial: false))

    #expect(
        analytics.records == [
            .propertySet(PurchaseCount(count: 1)),
            .tracked(PlanSelected(plan: "pro", seats: 1, price: 9.99, trial: false)),
        ])
}
test('the purchase count is set before the purchase event that should carry it', () async {
  final analytics = FakeAnalyticsProvider();
  final PropertyTrackerService properties = analytics;
  final EventTrackerService events = analytics;

  await properties.set(const PurchaseCount(1));
  await events.track(const PlanSelected('pro', seats: 1, price: 9.99, trial: false));

  expect(analytics.records, [isA<PropertySetRecord>(), isA<TrackedRecord>()]);
});
test('the purchase count is set before the purchase event that should carry it', async () => {
  const analytics = new FakeAnalyticsProvider();
  const properties: PropertyTrackerService = analytics;
  const events: EventTrackerService = analytics;

  await properties.set(new PurchaseCount(1));
  await events.track(new PlanSelected('pro', 1, 9.99, false));

  expect(analytics.records).toEqual([expect.any(PropertySetRecord), expect.any(TrackedRecord)]);
});

A smaller fake for one interface

A class that only tracks events doesn't need a Herald:

A lambda is the fake, with no extra dependency:

@Test
fun `a lambda is the smallest fake for one capability`() = runTest {
    val tracked = mutableListOf<Event>()

    CheckoutViewModel(analytics = EventTrackerService { tracked += it }).onCheckout("pro", 3)

    assertEquals(listOf<Event>(CheckoutStarted("pro", 3)), tracked)
}

FakeAnalyticsProvider is every protocol, so pass it to the class directly:

@Test func aClassThatTakesOneCapabilityCanBeGivenTheFakeDirectly() {
    let analytics = FakeAnalyticsProvider()

    CheckoutViewModel(analytics: analytics).onCheckout(plan: "pro", seats: 3)

    #expect(analytics.records == [.tracked(CheckoutStarted(plan: "pro", seats: 3))])
}

FakeAnalyticsProvider is every interface, so pass it to the class directly:

test('a class that takes one capability can be given the fake directly', () async {
  final analytics = FakeAnalyticsProvider();

  await CheckoutViewModel(analytics).onCheckout('pro', 3);

  expect(analytics.events, [const CheckoutStarted('pro', 3)]);
});

FakeAnalyticsProvider is every interface, so pass it to the class directly:

test('a class that takes one capability can be given the fake directly', async () => {
  const analytics = new FakeAnalyticsProvider();

  await new CheckoutViewModel(analytics).onCheckout('pro', 3);

  expect(analytics.events).toEqual([new CheckoutStarted('pro', 3)]);
});

Go through a real Herald when the test uses several interfaces or should run your factories.

Testing a factory

A feature's factory takes an event and returns a Resolution, with nothing else involved, so you can test it directly:

@Test
fun `the checkout factory claims its events and declines the rest`() {
    val factory = CheckoutFirebaseEventTrackerFactory(mockk<FirebaseAnalytics>())

    assertIs<Resolution.Claimed<*>>(factory.create(CheckoutCompleted(9.99, "EUR")))
    assertEquals(Resolution.Dropped, factory.create(CardNumberSeen("4242")))
    assertEquals(Resolution.Declined, factory.create(CheckoutStarted("pro", 3)))
}
@Test func theCheckoutFactoryClaimsItsEventsDropsTheCardNumberAndDeclinesTheRest() throws {
    let factory = CheckoutFirebaseEventTrackerFactory()

    let checkout = factory.create(CheckoutCompleted(value: 9.99, currency: "EUR"))
    #expect(try checkout.handlers().first is PurchaseFirebaseEventTracker)

    switch factory.create(CardNumberSeen(last4: "4242")) {
    case .dropped:
        break
    default:
        Issue.record("Expected the card number to be dropped")
    }

    switch factory.create(CheckoutStarted(plan: "pro", seats: 3)) {
    case .declined:
        break
    default:
        Issue.record("Expected another feature's event to be declined")
    }
}
test('the checkout factory claims its events and declines the rest', () {
  final factory = CheckoutFirebaseEventTrackerFactory(MockFirebaseAnalytics());

  expect(factory.create(const CheckoutCompleted(9.99, 'EUR')), isA<Claimed<Object>>());
  expect(factory.create(const CardNumberSeen('4242')), const Dropped());
  expect(factory.create(const CheckoutStarted('pro', 3)), const Declined());
});
test('the checkout factory claims its events and declines the rest', () => {
  const factory = new CheckoutFirebaseEventTrackerFactory({} as Analytics);

  expect(factory.create(new CheckoutCompleted(9.99, 'EUR')).kind).toBe('claimed');
  expect(factory.create(new CardNumberSeen('4242'))).toBe(Resolution.dropped());
  expect(factory.create(new CheckoutStarted('pro', 3))).toBe(Resolution.declined());
});

To assert what a tracker sends, mock the vendor SDK and verify the call. Build real events rather than mocked ones.

More examples

These test a small view model:

private struct CheckoutStarted: Event {
    let plan: String
    let seats: Int

    var name: String { "checkout_started" }
    var parameters: [String: AnalyticsValue] { ["plan": .string(plan), "seats": .int(seats)] }
}

private struct OrderPaid: Event {
    let total: Double

    var name: String { "order_paid" }
    var parameters: [String: AnalyticsValue] { ["total": .double(total)] }
}

private struct PurchaseCount: UserProperty {
    let count: Int

    var name: String { "total_purchases" }
    var value: AnalyticsValue { .int(count) }
}

private final class CheckoutViewModel {
    private let events: any EventTrackerService
    private let properties: any PropertyTrackerService

    init(events: any EventTrackerService, properties: any PropertyTrackerService) {
        self.events = events
        self.properties = properties
    }

    func start(plan: String, seats: Int) {
        events.track(CheckoutStarted(plan: plan, seats: seats))
    }

    func pay(total: Double, purchases: Int) {
        properties.set(PurchaseCount(count: purchases))
        events.track(OrderPaid(total: total))
    }
}

private final class SessionViewModel {
    private let identity: any IdentifiableUserService

    init(identity: any IdentifiableUserService) {
        self.identity = identity
    }

    func signIn(userId: String) {
        identity.identify(Identity(userId: userId))
    }

    func signOut() {
        identity.reset()
    }
}
final class const CheckoutStarted(final String plan, final int seats) extends Event {
  @override
  String get name => 'checkout_started';

  @override
  Map<String, AnalyticsValue> get parameters => {'plan': .string(plan), 'seats': .int(seats)};
}

final class const OrderPaid(final double total) extends Event {
  @override
  String get name => 'order_paid';

  @override
  Map<String, AnalyticsValue> get parameters => {'total': .double(total)};
}

final class const PurchaseCount(final int count) extends UserProperty {
  @override
  String get name => 'total_purchases';

  @override
  AnalyticsValue get value => .int(count);
}

final class CheckoutViewModel(
  final EventTrackerService events,
  final PropertyTrackerService properties,
) {
  Future<void> start(String plan, int seats) => events.track(CheckoutStarted(plan, seats));

  Future<void> pay(double total, int purchases) async {
    await properties.set(PurchaseCount(purchases));
    await events.track(OrderPaid(total));
  }
}

final class SessionViewModel(final IdentifiableUserService identity) {
  Future<void> signIn(String userId) => identity.identify(Identity(userId));

  Future<void> signOut() => identity.reset();
}
class CheckoutStarted implements Event {
  readonly name = 'checkout_started';

  constructor(
    readonly plan: string,
    readonly seats: number,
  ) {}

  get parameters(): EventParameters {
    return { plan: this.plan, seats: int(this.seats) };
  }
}

class OrderPaid implements Event {
  readonly name = 'order_paid';

  constructor(readonly total: number) {}

  get parameters(): EventParameters {
    return { total: double(this.total) };
  }
}

class PurchaseCount implements UserProperty {
  readonly name = 'total_purchases';
  readonly isUserProperty = true;

  constructor(readonly count: number) {}

  get value() {
    return int(this.count);
  }
}

class CheckoutViewModel {
  constructor(
    private readonly events: EventTrackerService,
    private readonly properties: PropertyTrackerService,
  ) {}

  start(plan: string, seats: number): Promise<void> {
    return this.events.track(new CheckoutStarted(plan, seats));
  }

  async pay(total: number, purchases: number): Promise<void> {
    await this.properties.set(new PurchaseCount(purchases));
    await this.events.track(new OrderPaid(total));
  }
}

class SessionViewModel {
  constructor(private readonly identity: IdentifiableUserService) {}

  signIn(userId: string): Promise<void> {
    return this.identity.identify(new Identity(userId));
  }

  signOut(): Promise<void> {
    return this.identity.reset();
  }
}

Values are compared by type, and assertTracked expects exactly one such event:

analytics.assertTracked("checkout_started") { event in
    event.param("plan", "pro")
    event.param("seats", 3)  // the number 3: the text "3" would fail
}
analytics.assertTracked(
  'checkout_started',
  (event) => event
    ..param('plan', 'pro')
    ..param('seats', 3), // the number 3: the text '3' would fail
);
analytics.assertTracked(
  'checkout_started',
  (event) => event.param('plan', 'pro').param('seats', int(3)), // the text '3' would fail
);

Repeats can be counted, and an event checked to be absent:

analytics.assertTrackedTimes("checkout_started", 2)  // repeats are expected
analytics.assertNotTracked("order_paid")
analytics.assertNothingElseTracked()  // every tracked event was checked above
analytics
  ..assertTrackedTimes('checkout_started', 2) // repeats are expected
  ..assertNotTracked('order_paid')
  ..assertNothingElseTracked(); // every tracked event was checked above
analytics
  .assertTrackedTimes('checkout_started', 2) // repeats are expected
  .assertNotTracked('order_paid')
  .assertNothingElseTracked(); // every tracked event was checked above

For a property, the last value counts:

let checkout = CheckoutViewModel(events: analytics, properties: analytics)
checkout.pay(total: 9.99, purchases: 1)
checkout.pay(total: 4.99, purchases: 2)

analytics.assertPropertySet("total_purchases", 2)  // 1 was overwritten by 2
final checkout = CheckoutViewModel(analytics, analytics);
await checkout.pay(9.99, 1);
await checkout.pay(4.99, 2);

analytics.assertPropertySet('total_purchases', 2); // 1 was overwritten by 2
const checkout = new CheckoutViewModel(analytics, analytics);
await checkout.pay(9.99, 1);
await checkout.pay(4.99, 2);

analytics.assertPropertySet('total_purchases', int(2)); // 1 was overwritten by 2

Sign-in and sign-out are recorded too:

let session = SessionViewModel(identity: analytics)
session.signIn(userId: "user-42")
session.signOut()

analytics.assertIdentified("user-42")  // passes even after the later reset
#expect(analytics.records == [.identified(Identity(userId: "user-42")), .reset])
final session = SessionViewModel(analytics);
await session.signIn('user-42');
await session.signOut();

analytics.assertIdentified('user-42'); // passes even after the later reset
expect(analytics.records, [const IdentifiedRecord(Identity('user-42')), const ResetRecord()]);
const session = new SessionViewModel(analytics);
await session.signIn('user-42');
await session.signOut();

analytics.assertIdentified('user-42'); // passes even after the later reset
expect(analytics.records).toEqual([
  new IdentifiedRecord(new Identity('user-42')),
  new ResetRecord(),
]);

For checks of your own, read the recorded events and properties:

analytics.assertTracked("order_paid") { event in
    let order = event.event as? OrderPaid
    #expect(abs((order?.total ?? 0) - 9.99) < 0.001)
}
#expect(analytics.events.count == 1)
#expect(analytics.properties.first?.value == .int(1))
analytics.assertTracked(
  'order_paid',
  (event) => expect((event.event as OrderPaid).total, closeTo(9.99, 0.001)),
);
expect(analytics.events.single, isA<OrderPaid>());
expect(analytics.properties.single.value, const AnalyticsInt(1));
analytics.assertTracked('order_paid', (event) => {
  expect((event.event as OrderPaid).total).toBeCloseTo(9.99);
});
expect(analytics.events).toEqual([expect.any(OrderPaid)]);
expect(AnalyticsValue.from(analytics.properties[0]!.value)).toEqual(int(1));

clear() forgets everything recorded so far, for tests with several steps:

let checkout = CheckoutViewModel(events: analytics, properties: analytics)
checkout.start(plan: "pro", seats: 3)
analytics.clear()  // forget what came before

checkout.pay(total: 9.99, purchases: 1)

analytics.assertTracked("order_paid")
analytics.assertNothingElseTracked()
final checkout = CheckoutViewModel(analytics, analytics);
await checkout.start('pro', 3);
analytics.clear(); // forget what came before

await checkout.pay(9.99, 1);

analytics
  ..assertTracked('order_paid')
  ..assertNothingElseTracked();
const checkout = new CheckoutViewModel(analytics, analytics);
await checkout.start('pro', 3);
analytics.clear(); // forget what came before

await checkout.pay(9.99, 1);

analytics.assertTracked('order_paid').assertNothingElseTracked();

Kotlin

With MockK, never stub a getter that returns an AnalyticsValue. It's a value class, and such stubs fail at random, depending on the JVM's state.