Unit tests
For each method, one or more test cases.
A test case consists of input parameter values and expected results.
All external classes should be stubbed using mock objects.
Unit Test Documentation
This document describes what each unit test file covers, how the mocks work, and what behaviors are being verified.
movementSensor.test.js
Purpose
Tests the movementSensor module (imported from ../src/hooks/movementSensor) that uses the Expo Accelerometer to:
- Register an accelerometer listener on module import
- Set the accelerometer update interval
- Track movement until a target travel distance is reached (arming a scan)
- Trigger a BLE scan only after the device becomes still for long enough
- Cancel a pending scan if movement resumes
- Clean up the accelerometer subscription via
stop()
Key Testing Techniques Used
Fake Timers
The file uses:
jest.useFakeTimers()to control time-based behavior (e.g., stillness timeouts).jest.advanceTimersByTime(ms)to simulate time passing and trigger timer callbacks.
This is essential because "stillness triggers scan after a timeout" is timer-driven behavior.
Mocking expo-sensors
The test replaces expo-sensors with a mock implementation:
-
Accelerometer.addListener(cb)is mocked to:- Capture the callback (
capturedListener) so tests can manually feed accelerometer samples. - Return a subscription object with
{ remove: mockRemove }so cleanup can be tested.
- Capture the callback (
-
Accelerometer.setUpdateIntervalis mocked so we can assert it was configured properly.
Mock variables:
mockAddListener: spy foraddListenermockSetUpdateInterval: spy forsetUpdateIntervalmockRemove: spy forsubscription.remove()capturedListener: stores the callback passed toaddListener
Loading a Fresh Module Each Test
movementSensor has top-level side effects (it registers listeners on import), so each test uses a helper:
jest.resetModules()to reset the Node module cachejest.isolateModules()to ensure therequire()happens in an isolated registry
This guarantees each test starts with a clean module state.
Test Setup / Teardown
beforeEach
- Clears mocks
- Spies on
console.logto verify logging behavior - Imports the module fresh via
loadFreshModule()
afterEach
- Restores the
console.logspy
Test Cases
1) Registers accelerometer listener and sets update interval on import
Test name
registers accelerometer listener and sets update interval on import
What it verifies
- Importing the module calls:
Accelerometer.setUpdateInterval(100)Accelerometer.addListener(...)
- Ensures
capturedListeneris actually a function (the update callback).
Why it matters
- Confirms that the module's top-level initialization is correct and consistent.
2) stop() removes the accelerometer subscription
Test name
stop() removes the accelerometer subscription
What it verifies
- Calling
mod.stop()triggerssubscription.remove()exactly once.
Why it matters
- Prevents memory leaks and duplicated listeners across app screens / lifecycle changes.
3) Does NOT scan when still but movement was never armed
Test name
does NOT scan when the device is still but movement was never armed
What it verifies
- Feeding a "still" sample without ever reaching the target distance does not trigger scanning, even after enough time passes.
Signals used
- Confirms
console.logwas not called with:[BLE] Scanning for nearby devices...
Why it matters
- Scanning should only happen after "arming" conditions are met (target distance reached).
4) After being armed, going still triggers a scan after the stillness timeout
Test name
after being armed, going still triggers a scan after the stillness timeout
What it verifies
- Movement samples accumulate enough distance to arm scanning.
- A log appears indicating arming:
[Tracker] 5m reached — will scan when device stops.
- A still sample starts the stillness timer.
- Before timeout: scan does not occur.
- After timeout: scan log appears:
[BLE] Scanning for nearby devices...
How time is simulated
- Mocks
Date.now()so distance calculations have deterministic timestamps. - Uses a loop that advances
nowby200msper movement sample to simulate sustained movement.
Why it matters
- This is the core expected behavior: "travel enough -> wait until still -> scan."
5) If movement resumes, it cancels the stillness timer (no scan)
Test name
if movement resumes, it cancels the stillness timer (no scan)
What it verifies
- Arms scanning the same way as the previous test.
- Starts the stillness timer by going still.
- Sends a movement sample before the stillness timeout completes.
- Advances time beyond the timeout.
- Confirms scan did not occur.
Why it matters
- Prevents scanning while the device is moving again (correct "debounce" behavior).
Notes / Expectations About the Implementation
These tests assume that movementSensor:
- Calls
Accelerometer.setUpdateInterval(100)on import - Calls
Accelerometer.addListener(cb)on import - Accumulates distance using time deltas (
Date.now()) and acceleration magnitude - Arms scanning at
5m(target distance) - Triggers scan only after
STILLNESS_TIMEOUTelapses while "still" - Cancels stillness timer if movement resumes before the timeout
useSentenceBuilder.test.js
Purpose
Tests the custom hook useSentenceBuilder (imported from ../src/hooks/useSentenceBuilder) which manages a "sentence" as an array of words and exposes actions to:
- Add a word
- Remove the last word
- Clear the sentence
- Speak the sentence (implemented here as an
Alert.alert) - Optionally log user events via an
onLogPresscallback
Key Testing Techniques Used
Hook Testing With renderHook
The tests use @testing-library/react-native:
renderHook(() => useSentenceBuilder(...))to run the hook in a test environmentact(() => ...)to apply state updates safely and ensure React state settles before assertions
Mocking Alert.alert
When testing speakSentence, the tests spy on:
Alert.alertto prevent UI popups and verify calls.
Test Setup
beforeEach
jest.clearAllMocks()ensures each test starts clean and no previous call counts leak in.
Test Cases
1) Initial sentence is empty
Test name
initial sentence is empty
What it verifies
- On first render,
sentenceis[].
2) addWord appends words in order
Test name
addWord appends words in order
What it verifies
- Adding
"hello", then"world"results in:['hello', 'world']
Why it matters
- Sentence ordering must match selection order for accurate speech output.
3) removeLastWord removes only the last word
Test name
removeLastWord removes only the last word
What it verifies
- Given
['I', 'like', 'pizza'], removing last yields:['I', 'like']
4) removeLastWord on empty sentence stays empty (no crash)
Test name
removeLastWord on empty sentence stays empty (no crash)
What it verifies
- Removing last word on an empty sentence:
- does not throw
- keeps
sentenceas[]
5) clearSentence resets sentence to empty
Test name
clearSentence resets sentence to empty
What it verifies
- After adding
"test", callingclearSentence()resets to[].
6) speakSentence does nothing when sentence is empty
Test name
speakSentence does nothing when sentence is empty
What it verifies
- With an empty sentence, calling
speakSentence():- does not call
Alert.alert
- does not call
Why it matters
- Avoids confusing UX (no "speaking" popup when nothing exists to speak).
7) speakSentence alerts joined sentence when not empty
Test name
speakSentence alerts joined sentence when not empty
What it verifies
- Adding
"hello","there"then callingspeakSentence()triggers:Alert.alert('Speaking', 'hello there')
Why it matters
- Confirms correct joining behavior and expected "speaking" output.
8) Calls onLogPress with correct event names and payloads
Test name
calls onLogPress with correct event names and payloads
What it verifies
When useSentenceBuilder({ onLogPress }) is used:
-
addWord('hi')logs:onLogPress('word_tile', { word: 'hi' })
-
removeLastWord()logs:onLogPress('remove_last_word')
-
clearSentence()logs:onLogPress('clear_sentence')
-
After adding two words and calling
speakSentence(), logs:onLogPress('speak_sentence', { sentenceLength: 2 })
Why it matters
- Ensures analytics / logging hooks receive consistent event names and payload shapes.
9) Does not crash if onLogPress is not provided
Test name
does not crash if onLogPress is not provided
What it verifies
- Calling all actions without an
onLogPresscallback:- does not throw any errors
Why it matters
- Makes the hook safe to use in screens/components that do not provide logging.
useSpeech.test.js
Purpose
Tests the useSpeech hook (imported from ../src/hooks/useSpeech) which wraps the TTS (text-to-speech) service. The hook exposes two functions:
speakText(text)— delegates to thespeakfunction from../services/ttsstopSpeech()— delegates to thestopfunction from../services/tts
Key Testing Techniques Used
Mocking ../services/tts
The test replaces the TTS service with mock functions:
speakis mocked as ajest.fn()to intercept calls and verify argumentsstopis mocked as ajest.fn()to intercept calls and verify invocation
Mock variables:
mockSpeak: spy forspeakmockStop: spy forstop
Hook Testing With renderHook
The tests use @testing-library/react-native:
renderHook(() => useSpeech())to run the hook in a test environment
Test Setup
beforeEach
jest.clearAllMocks()resets call counts and recorded arguments before each test.
Test Cases
1) speakText calls speak with provided text
Test name
speakText calls speak with provided text
What it verifies
- Calling
speakText('hello world')results in:speak('hello world')being called exactly once with the correct argument.
Why it matters
- Confirms
speakTextcorrectly passes through the text argument to the underlying TTS service without modification.
2) stopSpeech calls stop
Test name
stopSpeech calls stop
What it verifies
- Calling
stopSpeech()results in:stop()being called exactly once.
Why it matters
- Ensures the hook correctly delegates to the TTS service's
stopfunction to halt ongoing speech.
3) speakText with empty string still calls speak
Test name
speakText with empty string still calls speak
What it verifies
- Calling
speakText('')still callsspeak(''). - The hook does not guard against or swallow empty-string input.
Why it matters
- The filtering responsibility belongs to the caller; the hook should not silently drop calls.
4) speakText does not call stop
Test name
speakText does not call stop
What it verifies
- Calling
speakTextnever inadvertently callsstop.
5) stopSpeech does not call speak
Test name
stopSpeech does not call speak
What it verifies
- Calling
stopSpeechnever inadvertently callsspeak.
useSentence.test.js
Purpose
Tests the useSentence hook (imported from ../src/hooks/useSentence) which manages a sentence as an array of words, provides immediate per-word speech feedback, and exposes actions to:
- Add a word (and immediately speak it)
- Speak the full sentence
- Clear the sentence (and stop any ongoing speech)
Key Testing Techniques Used
Mocking ../services/tts
The test replaces the TTS service with mock functions:
speakis mocked as ajest.fn()to capture calls and verify argumentsstopis mocked as ajest.fn()to capture calls and verify invocation
Hook Testing With renderHook
The tests use @testing-library/react-native:
renderHook(() => useSentence())to run the hook in a test environmentact(() => ...)to safely apply state updates before assertions
Test Setup
beforeEach
jest.clearAllMocks()resets all mock state between tests.
Test Cases
1) Initial sentence is empty
Test name
initial sentence is empty
What it verifies
- On first render,
sentenceis[].
2) addWord appends the word to the sentence
Test name
addWord appends the word to the sentence
What it verifies
- Calling
addWord('hello')thenaddWord('world')results in:sentenceequal to['hello', 'world']
3) addWord immediately speaks the word
Test name
addWord immediately speaks the word
What it verifies
- Calling
addWord('hi')triggers:speak('hi')called exactly once with the correct argument.
Why it matters
- Immediate speech feedback is a core accessibility feature; the hook must call
speakon everyaddWordinvocation.
4) addWord speaks each word independently as they are added
Test name
addWord speaks each word independently as they are added
What it verifies
- Adding
'I','want','juice'triggersspeakthree times with each individual word in order.
Why it matters
- Confirms per-word feedback rather than a single batched call.
5) speakSentence speaks the full joined sentence
Test name
speakSentence speaks the full joined sentence
What it verifies
- After adding
"I","want","food", callingspeakSentence()triggers:speak('I want food')called once.
Why it matters
- Confirms that words are joined with spaces and the full sentence is passed as a single string to the TTS service.
6) speakSentence on empty sentence calls speak with empty string
Test name
speakSentence on empty sentence calls speak with empty string
What it verifies
- Calling
speakSentence()whensentenceis[]triggers:speak('')(since[].join(' ')is'').
Why it matters
- Documents and confirms the behaviour for edge case calls on an empty sentence; the hook does not guard against this.
7) clear resets the sentence to empty
Test name
clear resets the sentence to empty
What it verifies
- After adding
"test", callingclear()resetssentenceto[].
8) clear calls stop to halt ongoing speech
Test name
clear calls stop to halt ongoing speech
What it verifies
- Calling
clear()triggersstop()exactly once.
Why it matters
- Clearing a sentence while speech is in progress should immediately halt audio output to avoid confusion.
9) clear does not call speak
Test name
clear does not call speak
What it verifies
- Calling
clear()never produces new speech output.
usePictogram.test.js
Purpose
Tests the usePictogram hook (imported from ../src/hooks/usePictogram) which resolves a word or phrase label to an ARASAAC pictogram image URI. The hook:
- Accepts an optional
arasaacIdoverride for direct resolution without a network call - Uses an in-memory cache to avoid redundant fetches
- Applies search aliases to improve ARASAAC API query results
- Falls back to the last word of the label if the primary search returns no results
- Returns
{ uri, loading, error }
Also tests the exported helper getPictogramUrl(id).
Key Testing Techniques Used
Mocking fetch
The global fetch is replaced with a jest.fn() to:
- Return controlled JSON responses simulating ARASAAC API results
- Simulate network errors or empty result sets
Hook Testing With renderHook and waitFor
The tests use @testing-library/react-native:
renderHook(() => usePictogram(...))to run the hookwaitFor()to wait for asyncuseEffectstate updates to settle before assertions
Cache Isolation
Because usePictogram uses a module-level in-memory cache, tests that check network calls use unique label strings to avoid hitting cached results from previous tests.
Test Setup
beforeEach
jest.clearAllMocks()resets fetch spy call records.
Test Cases
1) getPictogramUrl builds the correct URL
Test name
getPictogramUrl builds the correct ARASAAC image URL
What it verifies
getPictogramUrl(12345)returns:'https://static.arasaac.org/pictograms/12345/12345_500.png'
2) Returns URI immediately when arasaacId is provided (no fetch)
Test name
returns URI immediately when arasaacId is provided (no fetch)
What it verifies
- With
usePictogram('anything', 9999):loadingisfalsefrom the initial renderuriequalsgetPictogramUrl(9999)fetchis never called
Why it matters
- The
arasaacIdoverride path must be instant and free of network calls.
3) Starts in loading state for an uncached label
Test name
starts in loading state for an uncached label
What it verifies
- Before the fetch resolves,
loadingistrueanduriisnull.
4) Fetches and resolves URI for a known label
Test name
fetches and resolves URI for a known label
What it verifies
- With a mocked fetch returning
[{ _id: 100, keywords: [{ keyword: 'apple' }] }]:- After resolution:
loading: false,uriequalsgetPictogramUrl(100),erroris null
- After resolution:
5) Uses SEARCH_ALIASES to transform the label before searching
Test name
uses SEARCH_ALIASES to transform the label before searching
What it verifies
usePictogram('eat')causesfetchto be called with a URL containing'eating'(the alias), not'eat'.
6) Falls back to last word if primary search fails
Test name
falls back to the last word of the label if primary search fails
What it verifies
- For a label like
'wash hands':- First fetch returns an empty array
- Second fetch is made with
'hands'(last word of label) - Final
uriresolves from the fallback result
7) Sets error state when all fetches fail
Test name
sets error state when all fetches fail
What it verifies
- When both the primary and fallback fetches fail:
loading: false,uri: null,erroris a non-null string
8) Caches result and does not re-fetch for the same label
Test name
caches result and does not re-fetch for the same label
What it verifies
- Rendering
usePictogram('banana')twice callsfetchonly once (second render hits the cache).
9) Sets error when fetch returns a non-ok response
Test name
sets error when fetch returns a non-ok response
What it verifies
- An HTTP error status results in
uri: nulland a non-nullerrorstring.
useLocationDetection.test.js
Purpose
Tests the useLocationDetection hook (imported from ../src/hooks/useLocationDetection) which provides an abstraction for room/location detection. Currently it supports manual room selection and is designed to be extended with Bluetooth beacon detection. The hook exposes:
currentRoom— the currently active room object, ornulldetectionMode— the string'manual'setRoomManually(roomId)— selects a room by IDallRooms— the full list of available room definitions
Key Testing Techniques Used
Mocking ../data/roomContexts
The test replaces the data layer with controlled mock data:
getAllRoomsis mocked to return a predictable array of room objectsgetRoomByBeaconIdis mocked as ajest.fn()for future BLE use cases
Hook Testing With renderHook
The tests use @testing-library/react-native:
renderHook(() => useLocationDetection())to run the hookact(() => ...)to apply state changes before assertions
Test Setup
beforeEach
jest.clearAllMocks()resets mock call records.
Test Cases
1) Initial state has no current room
Test name
initial state has no current room
What it verifies
- On first render,
currentRoomisnull.
2) detectionMode is 'manual'
Test name
detectionMode is "manual"
What it verifies
detectionModeis'manual'(BLE is not yet wired).
3) allRooms returns all room definitions
Test name
allRooms returns all room definitions from the data layer
What it verifies
allRoomsequals the array returned by the mockedgetAllRooms().
Why it matters
- Confirms the hook correctly exposes the full room list to consumers without modification.
4) setRoomManually sets the current room by matching ID
Test name
setRoomManually sets the current room by matching ID
What it verifies
- Calling
setRoomManually('kitchen')updatescurrentRoomto{ id: 'kitchen', label: 'Kitchen' }.
5) setRoomManually switches between rooms correctly
Test name
setRoomManually switches between rooms correctly
What it verifies
- Moving from
'kitchen'to'bedroom'updatescurrentRoomeach time.
6) setRoomManually with unknown ID sets current room to null
Test name
setRoomManually with an unknown ID sets currentRoom to null
What it verifies
- Calling
setRoomManually('nonexistent_room')results incurrentRoombeingnull.
Why it matters
- Prevents a stale or incorrect room from being displayed when an unrecognised ID is provided.
7) setRoomManually with null clears the current room
Test name
setRoomManually with null clears the current room
What it verifies
- After setting a room, calling
setRoomManually(null)resetscurrentRoomtonull.
8) setRoomManually does not throw for any input
Test name
setRoomManually does not throw for any input
What it verifies
- Passing
undefined,'', ornulltosetRoomManuallynever throws.
Why it matters
- Defensive robustness against unexpected argument types from the BLE layer.
useInteractionLogger.test.js
Purpose
Tests the useInteractionLogger hook (imported from ../src/hooks/useInteractionLogger) which records user interaction events with structured metadata. The hook:
- Generates a device ID on mount derived from
Platform.OSandPlatform.Version - Appends log entries to
interactionLogsstate on eachlogButtonPresscall - Associates each entry with a room location (from
currentRoom) or falls back to'general' - Emits each entry to
console.log
Key Testing Techniques Used
Mocking react-native
The test mocks Platform to control the device ID format:
Platform.OSset to'ios'Platform.Versionset to'16'
Mocking console.log
A spy on console.log captures emitted log entries for assertion.
Hook Testing With renderHook
The tests use @testing-library/react-native:
renderHook(() => useInteractionLogger(currentRoom))with varyingcurrentRoomvalues
Test Setup
beforeEach
jest.clearAllMocks()resets all spies.logSpyis set up onconsole.log.
afterEach
logSpy.mockRestore()restores the originalconsole.log.
Test Cases
1) deviceId is generated in the expected format
Test name
deviceId is generated in the expected format
What it verifies
deviceIdmatches the pattern'ios-16-<random>'.- Starts with
'ios-16-'and has at least one additional character.
2) interactionLogs starts empty
Test name
interactionLogs starts empty
What it verifies
- On first render,
interactionLogsis[].
3) logButtonPress appends an entry with the correct structure
Test name
logButtonPress appends an entry with the correct structure
What it verifies
- One entry is appended containing
deviceId,buttonName, a valid ISO 8601pressedAt, andlocation: { id: 'general', label: 'General' }(whencurrentRoomis null).
4) logButtonPress uses room location when currentRoom is provided
Test name
logButtonPress uses room location when currentRoom is provided
What it verifies
- With
currentRoom = { id: 'kitchen', label: 'Kitchen' }, the entry'slocationequals{ id: 'kitchen', label: 'Kitchen' }.
5) logButtonPress falls back to general when currentRoom is null
Test name
logButtonPress falls back to general location when currentRoom is null
What it verifies
- With no room set,
locationequals{ id: 'general', label: 'General' }.
6) logButtonPress merges additional metadata into the entry
Test name
logButtonPress merges additional metadata into the entry
What it verifies
- Calling
logButtonPress('word_tile', { word: 'hello' })produces an entry whereentry.word === 'hello'.
7) logButtonPress emits entry to console.log
Test name
logButtonPress emits entry to console.log
What it verifies
console.logis called with'[InteractionLog]'and a valid JSON string after each press.
8) Multiple logButtonPress calls accumulate entries in order
Test name
multiple logButtonPress calls accumulate entries in order
What it verifies
- Three calls result in
interactionLogs.length === 3with entries in call order.
9) logButtonPress with no metadata does not crash
Test name
logButtonPress with no metadata does not crash
What it verifies
- Calling
logButtonPresswith only a button name (no metadata) does not throw.
Summary
movementSensor.test.js— sensor lifecycle and movement/stillness logic, mocked Expo Accelerometer, controlled timers.useSentenceBuilder.test.js— sentence state management, speech output viaAlert.alert, optional event logging.useSpeech.test.js— thin TTS wrapper, correct delegation ofspeakTextandstopSpeech.useSentence.test.js— sentence state with immediate per-word feedback,clear/stopintegration.usePictogram.test.js— pictogram resolution:arasaacIdoverride, caching, alias lookup, fallback search, error states.useLocationDetection.test.js— manual room selection, null handling, correct exposure of room data.useInteractionLogger.test.js— log entry structure, device ID, room location association, metadata merging,console.logoutput.