test: add QA coverage — access unit tests, VideoPlayer snapshots, Maestro E2E flows, testIDs

- Add testIDs to explore, workout detail, and collection detail screens
- Add testID prop to VideoPlayer component
- Create access service unit tests (isFreeWorkout, canAccessWorkout)
- Create VideoPlayer rendering snapshot tests (preview/background modes)
- Create Maestro E2E flows: explore-freemium, collection-detail
- Update tab-navigation flow with Explore screen assertions
- Update profile-settings flow with real activity stat assertions
- Update all-tests suite to include new flows
This commit is contained in:
Millian Lamiaux
2026-03-24 12:40:02 +01:00
parent a042c348c1
commit 4fa8be600c
12 changed files with 742 additions and 13 deletions

View File

@@ -17,6 +17,12 @@ env:
# Run tab navigation
- runFlow: ./tab-navigation.yaml
# Run explore freemium (lock badges, paywall gating)
- runFlow: ./explore-freemium.yaml
# Run collection detail
- runFlow: ./collection-detail.yaml
# Run workout player
- runFlow: ./workout-player.yaml

View File

@@ -0,0 +1,93 @@
# Collection Detail Test
# Tests navigating to a collection and viewing its workouts
# Prerequisite: User must have completed onboarding
appId: com.millianlmx.tabatafit
name: Collection Detail
---
# Navigate to Explore tab
- tapOn:
text: "Explore"
optional: true
- tapOn:
id: "explore-tab"
optional: true
# Verify Explore screen loaded
- assertVisible:
id: "explore-screen"
timeout: 5000
# Verify collections section
- assertVisible:
id: "collections-section"
timeout: 3000
optional: true
# Tap the first collection card
- tapOn:
text: ".*collection.*"
optional: true
# If collection-card testIDs are visible, tap by testID instead
- tapOn:
id: "collection-card-.*"
optional: true
# Verify collection detail screen loaded
- assertVisible:
id: "collection-detail-screen"
timeout: 5000
optional: true
# Verify hero card is visible
- assertVisible:
id: "collection-hero"
timeout: 3000
optional: true
# Verify back button exists
- assertVisible:
id: "collection-back-button"
timeout: 3000
optional: true
# Verify workouts are listed
- assertVisible:
text: ".*Workout.*"
timeout: 3000
optional: true
# Scroll to see more workouts
- scroll:
direction: DOWN
duration: 500
# Tap a workout in the collection
- tapOn:
id: "collection-workout-.*"
optional: true
# Verify workout detail opened
- assertVisible:
id: "workout-detail-screen"
timeout: 5000
optional: true
# Go back to collection
- pressKey: back
optional: true
# Go back to explore via back button
- tapOn:
id: "collection-back-button"
optional: true
# Navigate back to Home
- tapOn:
text: "Home"
optional: true
- tapOn:
id: "home-tab"
optional: true

View File

@@ -0,0 +1,106 @@
# Explore Tab Freemium Test
# Tests lock badges on non-free workouts, free workout access,
# and paywall gating for locked workouts.
# Prerequisite: User must have completed onboarding (free user, not premium)
appId: com.millianlmx.tabatafit
name: Explore Freemium
---
# Navigate to Explore tab
- tapOn:
text: "Explore"
optional: true
- tapOn:
id: "explore-tab"
optional: true
# Verify Explore screen loaded
- assertVisible:
id: "explore-screen"
timeout: 5000
# Verify collections section is visible
- assertVisible:
id: "collections-section"
timeout: 3000
optional: true
# Verify featured section is visible
- assertVisible:
id: "featured-section"
timeout: 3000
optional: true
# Verify filters section is visible
- assertVisible:
id: "filters-section"
timeout: 3000
# Scroll down to see workout cards
- scroll:
direction: DOWN
duration: 500
# Tap a free workout (ID 1 — Full Body Ignite) — should go to detail, not paywall
- tapOn:
id: "workout-card-1"
optional: true
# On workout detail: verify start button (not unlock)
- assertVisible:
id: "workout-start-button"
timeout: 5000
optional: true
# Verify video preview is rendered
- assertVisible:
id: "workout-video-preview"
timeout: 3000
optional: true
# Go back to explore
- pressKey: back
optional: true
- tapOn:
text: "Explore"
optional: true
# Scroll to find a locked workout
- scroll:
direction: DOWN
duration: 800
# Tap a locked workout (ID 2 — not in free tier)
- tapOn:
id: "workout-card-2"
optional: true
# On workout detail: verify unlock/locked button
- assertVisible:
id: "workout-unlock-button"
timeout: 5000
optional: true
# Tap unlock button — should navigate to paywall
- tapOn:
id: "workout-unlock-button"
optional: true
# Verify paywall screen appeared
- assertVisible:
text: ".*Premium.*"
timeout: 5000
optional: true
# Go back from paywall
- pressKey: back
optional: true
# Navigate back to Home
- tapOn:
text: "Home"
optional: true
- tapOn:
id: "home-tab"
optional: true

View File

@@ -28,11 +28,19 @@ name: Profile Settings
timeout: 3000
optional: true
# Check stats are visible
# Check stats section — real activity store data (may show 0 if no workouts done)
- assertVisible:
text: ".*workout.*"
timeout: 3000
optional: true
- assertVisible:
text: ".*min.*"
timeout: 3000
optional: true
- assertVisible:
text: ".*cal.*"
timeout: 3000
optional: true
# Scroll to settings section
- scroll:

View File

@@ -17,6 +17,16 @@ name: Tab Navigation
id: "explore-tab"
optional: true
# Verify Explore screen loaded with key sections
- assertVisible:
id: "explore-screen"
timeout: 5000
optional: true
- assertVisible:
id: "filters-section"
timeout: 3000
optional: true
# Navigate to Activity tab
- tapOn:
text: "Activity"

View File

@@ -77,6 +77,7 @@ function CollectionCard({
return (
<Pressable
testID={`collection-card-${title.toLowerCase().replace(/\s+/g, '-')}`}
style={styles.collectionCard}
onPress={() => {
haptics.buttonTap()
@@ -121,6 +122,7 @@ function WorkoutCard({
return (
<Pressable
testID={`workout-card-${workout.id}`}
style={[styles.workoutCard, { borderColor: colors.border.glassLight }]}
onPress={onPress}
>
@@ -138,7 +140,7 @@ function WorkoutCard({
</View>
{isLocked && (
<View style={styles.lockBadge}>
<View testID={`lock-badge-${workout.id}`} style={styles.lockBadge}>
<Ionicons name="lock-closed" size={10} color="#FFFFFF" />
</View>
)}
@@ -279,8 +281,9 @@ export default function ExploreScreen() {
}
return (
<View style={[styles.container, { backgroundColor: colors.bg.base, paddingTop: insets.top }]}>
<View testID="explore-screen" style={[styles.container, { backgroundColor: colors.bg.base, paddingTop: insets.top }]}>
<ScrollView
testID="explore-scroll"
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
@@ -295,7 +298,7 @@ export default function ExploreScreen() {
</View>
{collections.length > 0 && (
<View style={styles.collectionsSection}>
<View testID="collections-section" style={styles.collectionsSection}>
<StyledText size={22} weight="bold" color={colors.text.primary}>
{t('screens:explore.collections')}
</StyledText>
@@ -320,7 +323,7 @@ export default function ExploreScreen() {
)}
{featured.length > 0 && (
<View style={styles.section}>
<View testID="featured-section" style={styles.section}>
<StyledText size={22} weight="bold" color={colors.text.primary}>
{t('screens:explore.featured')}
</StyledText>
@@ -337,7 +340,7 @@ export default function ExploreScreen() {
</View>
)}
<View style={styles.filtersSection}>
<View testID="filters-section" style={styles.filtersSection}>
<View style={styles.filterHeader}>
<StyledText size={22} weight="bold" color={colors.text.primary}>
{t('screens:explore.allWorkouts')}

View File

@@ -79,10 +79,10 @@ export default function CollectionDetailScreen() {
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View testID="collection-detail-screen" style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<Pressable onPress={handleBack} style={styles.backButton}>
<Pressable testID="collection-back-button" onPress={handleBack} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color={colors.text.primary} />
</Pressable>
<StyledText size={22} weight="bold" color={colors.text.primary} numberOfLines={1} style={{ flex: 1, textAlign: 'center' }}>
@@ -97,7 +97,7 @@ export default function CollectionDetailScreen() {
showsVerticalScrollIndicator={false}
>
{/* Hero Card */}
<View style={styles.heroCard}>
<View testID="collection-hero" style={styles.heroCard}>
<LinearGradient
colors={collection.gradient ?? [BRAND.PRIMARY, '#FF3B30']}
start={{ x: 0, y: 0 }}
@@ -133,6 +133,7 @@ export default function CollectionDetailScreen() {
{workouts.map((workout) => (
<Pressable
key={workout.id}
testID={`collection-workout-${workout.id}`}
style={styles.workoutCard}
onPress={() => handleWorkoutPress(workout.id)}
>

View File

@@ -90,7 +90,7 @@ export default function WorkoutDetailScreen() {
const repeatCount = Math.max(1, Math.floor(workout.rounds / workout.exercises.length))
return (
<View style={styles.container}>
<View testID="workout-detail-screen" style={styles.container}>
{/* Header with SwiftUI glass button */}
<View style={[styles.header, { paddingTop: insets.top + SPACING[3] }]}>
<RNText style={styles.headerTitle} numberOfLines={1}>
@@ -131,6 +131,7 @@ export default function WorkoutDetailScreen() {
mode="preview"
isPlaying={true}
style={styles.videoPreview}
testID="workout-video-preview"
/>
{/* Quick stats */}
<View style={styles.quickStats}>
@@ -204,6 +205,7 @@ export default function WorkoutDetailScreen() {
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
<Pressable
testID={isLocked ? 'workout-unlock-button' : 'workout-start-button'}
style={({ pressed }) => [
styles.startButton,
isLocked && styles.lockedButton,
@@ -214,7 +216,7 @@ export default function WorkoutDetailScreen() {
{isLocked && (
<Ionicons name="lock-closed" size={18} color="#FFFFFF" style={{ marginRight: 8 }} />
)}
<RNText style={styles.startButtonText}>
<RNText testID="workout-cta-text" style={styles.startButtonText}>
{isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')}
</RNText>
</Pressable>

View File

@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import React from 'react'
import { render } from '@testing-library/react-native'
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
describe('VideoPlayer rendering', () => {
describe('preview mode', () => {
it('renders gradient fallback when no videoUrl', () => {
const { toJSON } = render(
<VideoPlayer mode="preview" isPlaying={false} />
)
const tree = toJSON()
expect(tree).toBeTruthy()
expect(tree).toMatchSnapshot()
})
it('renders video view when videoUrl is provided', () => {
const { toJSON } = render(
<VideoPlayer
videoUrl="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8"
mode="preview"
isPlaying={true}
/>
)
const tree = toJSON()
expect(tree).toBeTruthy()
expect(tree).toMatchSnapshot()
})
it('renders with custom style', () => {
const { toJSON } = render(
<VideoPlayer
mode="preview"
style={{ height: 220, borderRadius: 20 }}
/>
)
expect(toJSON()).toMatchSnapshot()
})
it('renders with testID prop', () => {
const { getByTestId } = render(
<VideoPlayer
mode="preview"
testID="my-video-player"
/>
)
expect(getByTestId('my-video-player')).toBeTruthy()
})
})
describe('background mode', () => {
it('renders gradient fallback when no videoUrl', () => {
const { toJSON } = render(
<VideoPlayer mode="background" isPlaying={false} />
)
expect(toJSON()).toMatchSnapshot()
})
it('renders video view when videoUrl is provided', () => {
const { toJSON } = render(
<VideoPlayer
videoUrl="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8"
mode="background"
isPlaying={true}
/>
)
expect(toJSON()).toMatchSnapshot()
})
})
describe('custom gradient colors', () => {
it('renders with custom gradient colors when no video', () => {
const { toJSON } = render(
<VideoPlayer
gradientColors={['#FF0000', '#0000FF']}
mode="preview"
/>
)
expect(toJSON()).toMatchSnapshot()
})
})
})

View File

@@ -0,0 +1,292 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`VideoPlayer rendering > background mode > renders gradient fallback when no videoUrl 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000",
"overflow": "hidden",
},
undefined,
]
}
>
<LinearGradient
colors={
[
"#FF6B35",
"#E55A25",
]
}
end={
{
"x": 1,
"y": 1,
}
}
start={
{
"x": 0,
"y": 0,
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="linear-gradient"
/>
</View>
`;
exports[`VideoPlayer rendering > background mode > renders video view when videoUrl is provided 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000",
"overflow": "hidden",
},
undefined,
]
}
>
<VideoView
contentFit="cover"
nativeControls={false}
player={
{
"currentTime": 0,
"duration": 100,
"muted": false,
"pause": [MockFunction],
"play": [MockFunction] {
"calls": [
[],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
},
"playing": false,
"replace": [MockFunction],
"volume": 1,
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="video-view"
/>
</View>
`;
exports[`VideoPlayer rendering > custom gradient colors > renders with custom gradient colors when no video 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000",
"overflow": "hidden",
},
undefined,
]
}
>
<LinearGradient
colors={
[
"#FF0000",
"#0000FF",
]
}
end={
{
"x": 1,
"y": 1,
}
}
start={
{
"x": 0,
"y": 0,
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="linear-gradient"
/>
</View>
`;
exports[`VideoPlayer rendering > preview mode > renders gradient fallback when no videoUrl 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000",
"overflow": "hidden",
},
undefined,
]
}
>
<LinearGradient
colors={
[
"#FF6B35",
"#E55A25",
]
}
end={
{
"x": 1,
"y": 1,
}
}
start={
{
"x": 0,
"y": 0,
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="linear-gradient"
/>
</View>
`;
exports[`VideoPlayer rendering > preview mode > renders video view when videoUrl is provided 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000",
"overflow": "hidden",
},
undefined,
]
}
>
<VideoView
contentFit="cover"
nativeControls={false}
player={
{
"currentTime": 0,
"duration": 100,
"muted": false,
"pause": [MockFunction],
"play": [MockFunction] {
"calls": [
[],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
},
"playing": false,
"replace": [MockFunction],
"volume": 1,
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="video-view"
/>
</View>
`;
exports[`VideoPlayer rendering > preview mode > renders with custom style 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000",
"overflow": "hidden",
},
{
"borderRadius": 20,
"height": 220,
},
]
}
>
<LinearGradient
colors={
[
"#FF6B35",
"#E55A25",
]
}
end={
{
"x": 1,
"y": 1,
}
}
start={
{
"x": 0,
"y": 0,
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="linear-gradient"
/>
</View>
`;

View File

@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest'
import {
FREE_WORKOUT_IDS,
FREE_WORKOUT_COUNT,
isFreeWorkout,
canAccessWorkout,
} from '../../shared/services/access'
describe('access service', () => {
describe('FREE_WORKOUT_IDS', () => {
it('should contain exactly 3 free workout IDs', () => {
expect(FREE_WORKOUT_IDS).toHaveLength(3)
})
it('should include workout 1 (Full Body Ignite)', () => {
expect(FREE_WORKOUT_IDS).toContain('1')
})
it('should include workout 11 (Core Crusher)', () => {
expect(FREE_WORKOUT_IDS).toContain('11')
})
it('should include workout 43 (Dance Cardio)', () => {
expect(FREE_WORKOUT_IDS).toContain('43')
})
it('should be readonly (immutable)', () => {
// TypeScript enforces readonly at compile time; at runtime we verify the array content is stable
const snapshot = [...FREE_WORKOUT_IDS]
expect(FREE_WORKOUT_IDS).toEqual(snapshot)
})
})
describe('FREE_WORKOUT_COUNT', () => {
it('should equal the length of FREE_WORKOUT_IDS', () => {
expect(FREE_WORKOUT_COUNT).toBe(FREE_WORKOUT_IDS.length)
})
it('should be 3', () => {
expect(FREE_WORKOUT_COUNT).toBe(3)
})
})
describe('isFreeWorkout', () => {
it('should return true for free workout ID "1"', () => {
expect(isFreeWorkout('1')).toBe(true)
})
it('should return true for free workout ID "11"', () => {
expect(isFreeWorkout('11')).toBe(true)
})
it('should return true for free workout ID "43"', () => {
expect(isFreeWorkout('43')).toBe(true)
})
it('should return false for non-free workout ID "2"', () => {
expect(isFreeWorkout('2')).toBe(false)
})
it('should return false for non-free workout ID "10"', () => {
expect(isFreeWorkout('10')).toBe(false)
})
it('should return false for non-free workout ID "44"', () => {
expect(isFreeWorkout('44')).toBe(false)
})
it('should return false for empty string', () => {
expect(isFreeWorkout('')).toBe(false)
})
it('should return false for non-existent ID', () => {
expect(isFreeWorkout('999')).toBe(false)
})
})
describe('canAccessWorkout', () => {
describe('premium user', () => {
it('should access free workout', () => {
expect(canAccessWorkout('1', true)).toBe(true)
})
it('should access non-free workout', () => {
expect(canAccessWorkout('2', true)).toBe(true)
})
it('should access any workout ID', () => {
expect(canAccessWorkout('999', true)).toBe(true)
})
})
describe('free user', () => {
it('should access free workout ID "1"', () => {
expect(canAccessWorkout('1', false)).toBe(true)
})
it('should access free workout ID "11"', () => {
expect(canAccessWorkout('11', false)).toBe(true)
})
it('should access free workout ID "43"', () => {
expect(canAccessWorkout('43', false)).toBe(true)
})
it('should NOT access non-free workout ID "2"', () => {
expect(canAccessWorkout('2', false)).toBe(false)
})
it('should NOT access non-free workout ID "10"', () => {
expect(canAccessWorkout('10', false)).toBe(false)
})
it('should NOT access non-free workout ID "50"', () => {
expect(canAccessWorkout('50', false)).toBe(false)
})
it('should NOT access empty string ID', () => {
expect(canAccessWorkout('', false)).toBe(false)
})
})
})
})

View File

@@ -20,6 +20,8 @@ interface VideoPlayerProps {
/** Whether to play the video */
isPlaying?: boolean
style?: object
/** Test identifier for QA automation */
testID?: string
}
export function VideoPlayer({
@@ -28,6 +30,7 @@ export function VideoPlayer({
mode = 'preview',
isPlaying = true,
style,
testID,
}: VideoPlayerProps) {
const player = useVideoPlayer(videoUrl ?? null, (p) => {
p.loop = true
@@ -47,7 +50,7 @@ export function VideoPlayer({
// No video URL — show gradient fallback
if (!videoUrl) {
return (
<View style={[styles.container, style]}>
<View testID={testID} style={[styles.container, style]}>
<LinearGradient
colors={gradientColors}
start={{ x: 0, y: 0 }}
@@ -59,7 +62,7 @@ export function VideoPlayer({
}
return (
<View style={[styles.container, style]}>
<View testID={testID} style={[styles.container, style]}>
<VideoView
player={player}
style={StyleSheet.absoluteFill}