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:
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.
Its assertions throw a TestFailure, which package:test and flutter_test report like any
failed expect.
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();
});
assertTrackedexpects exactly one match. A double tap that sends an event twice fails the test instead of passing quietly. UseassertTrackedTimeswhen 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:
FakeAnalyticsProvider is every protocol, so pass it to the class directly:
FakeAnalyticsProvider is every interface, so pass it to the class directly:
FakeAnalyticsProvider is every interface, so pass it to the class directly:
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:
Repeats can be counted, and an event checked to be absent:
For a property, the last value counts:
Sign-in and sign-out are recorded too:
For checks of your own, read the recorded events and properties:
clear() forgets everything recorded so far, for tests with several steps:
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.