Skip to content

Commit

Permalink
Add Tests to HealthKit Visualizations (#49)
Browse files Browse the repository at this point in the history
# *Add Tests to HealthKit Visualizations*

## ♻️ Current situation & Problem
Currently, we are missing test coverages for HealthKit visualizations,
especially for the codes requiring access to the HealthKit data. This PR
aims to cover those codes by (1) grouping codes parsing the HealthKit
data as single functions to be tested with unit tests, and (2) using
mock data for testing. Currently, we are still missing code coverage for
reading HKStatistics as we are not able to manually initialize this
class, and some auxiliary codes such as setting the states with parsed
HealthKit, but the majority of the codes are covered. Overall, 402 out
of 472 (~85%) lines for HealthKit visualization are now covered.

## ⚙️ Release Notes 
- Refactor part of the codes to group codes parsing the HealthKit data
as single functions
- Add unit tests for functions that parse the HKQuantity(s)
- Add a feature flag `--mockTestData` to define whether we need to use
the mock data for testing
- Update the UI test to use the mock test data
- Add codes to tap on screens to trigger and verify lollipops and
details showings for the visualizations during UI tests.

## 📚 Documentation
Related comments are added.

## ✅ Testing
Unit tests and UI tests are added.

## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/CS342/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/CS342/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/CS342/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/CS342/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
YurenSUN authored Mar 8, 2024
1 parent 380ea88 commit a7c6c16
Show file tree
Hide file tree
Showing 8 changed files with 213 additions and 64 deletions.
17 changes: 9 additions & 8 deletions PICS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@
2FE5DC8C29EDD972004B9AB4 /* SpeziSecureStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC8B29EDD972004B9AB4 /* SpeziSecureStorage */; };
2FE5DC8F29EDD980004B9AB4 /* SpeziViews in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC8E29EDD980004B9AB4 /* SpeziViews */; };
2FE5DC9929EDD9D9004B9AB4 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC9829EDD9D9004B9AB4 /* XCTestExtensions */; };
2FE5DC9C29EDD9EF004B9AB4 /* XCTHealthKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC9B29EDD9EF004B9AB4 /* XCTHealthKit */; };
2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DCAC29EE6107004B9AB4 /* AccountOnboarding.swift */; };
2FF53D8B2A8725DE00042B76 /* SpeziMockWebService in Frameworks */ = {isa = PBXBuildFile; productRef = 2FF53D8A2A8725DE00042B76 /* SpeziMockWebService */; };
2FF53D8D2A8729D600042B76 /* PICSStandard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF53D8C2A8729D600042B76 /* PICSStandard.swift */; };
Expand Down Expand Up @@ -103,6 +102,7 @@
9739A0C62AD7B5730084BEA5 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 9739A0C52AD7B5730084BEA5 /* FirebaseStorage */; };
97D73D6A2AD860AD00B47FA0 /* SpeziFirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 97D73D692AD860AD00B47FA0 /* SpeziFirebaseStorage */; };
A403A52E2B705A8C003CFA5C /* HealthVisualizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A403A52D2B705A8C003CFA5C /* HealthVisualizationTests.swift */; };
A40559A42B98204C00221783 /* HKVizUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40559A32B98204C00221783 /* HKVizUnitTests.swift */; };
A45993292B90544300A98C95 /* Assessments.swift in Sources */ = {isa = PBXBuildFile; fileRef = A45993282B90544300A98C95 /* Assessments.swift */; };
A459932C2B906C3B00A98C95 /* ResultsViz.swift in Sources */ = {isa = PBXBuildFile; fileRef = A459932B2B906C3B00A98C95 /* ResultsViz.swift */; };
A480C7C02B6D5A3700B29A07 /* HKVisualization.swift in Sources */ = {isa = PBXBuildFile; fileRef = A480C7BF2B6D5A3700B29A07 /* HKVisualization.swift */; };
Expand Down Expand Up @@ -205,6 +205,7 @@
86EB7FF62B8FB7A000D52EE2 /* NotificationPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissions.swift; sourceTree = "<group>"; };
86F62AF52B916B670075F23C /* AppointmentInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppointmentInformation.swift; sourceTree = "<group>"; };
A403A52D2B705A8C003CFA5C /* HealthVisualizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthVisualizationTests.swift; sourceTree = "<group>"; };
A40559A32B98204C00221783 /* HKVizUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKVizUnitTests.swift; sourceTree = "<group>"; };
A45993282B90544300A98C95 /* Assessments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assessments.swift; sourceTree = "<group>"; };
A459932B2B906C3B00A98C95 /* ResultsViz.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultsViz.swift; sourceTree = "<group>"; };
A480C7BF2B6D5A3700B29A07 /* HKVisualization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKVisualization.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -258,7 +259,6 @@
buildActionMask = 2147483647;
files = (
2FE5DC9929EDD9D9004B9AB4 /* XCTestExtensions in Frameworks */,
2FE5DC9C29EDD9EF004B9AB4 /* XCTHealthKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -438,6 +438,7 @@
isa = PBXGroup;
children = (
653A256128338800005D4D48 /* PICSTests.swift */,
A40559A32B98204C00221783 /* HKVizUnitTests.swift */,
);
path = PICSTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -566,6 +567,8 @@
653A255F28338800005D4D48 /* PBXTargetDependency */,
);
name = PICSTests;
packageProductDependencies = (
);
productName = PICSTests;
productReference = 653A255D28338800005D4D48 /* PICSTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
Expand All @@ -586,7 +589,6 @@
name = PICSUITests;
packageProductDependencies = (
2FE5DC9829EDD9D9004B9AB4 /* XCTestExtensions */,
2FE5DC9B29EDD9EF004B9AB4 /* XCTHealthKit */,
);
productName = PICSUITests;
productReference = 653A256728338800005D4D48 /* PICSUITests.xctest */;
Expand Down Expand Up @@ -790,6 +792,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A40559A42B98204C00221783 /* HKVizUnitTests.swift in Sources */,
653A256228338800005D4D48 /* PICSTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -896,6 +899,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
Expand Down Expand Up @@ -1101,6 +1105,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
Expand Down Expand Up @@ -1148,6 +1153,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 637867499T;
Expand Down Expand Up @@ -1549,11 +1555,6 @@
package = 2FE5DC9729EDD9D9004B9AB4 /* XCRemoteSwiftPackageReference "XCTestExtensions" */;
productName = XCTestExtensions;
};
2FE5DC9B29EDD9EF004B9AB4 /* XCTHealthKit */ = {
isa = XCSwiftPackageProductDependency;
package = 2FE5DC9A29EDD9EF004B9AB4 /* XCRemoteSwiftPackageReference "XCTHealthKit" */;
productName = XCTHealthKit;
};
2FF53D8A2A8725DE00042B76 /* SpeziMockWebService */ = {
isa = XCSwiftPackageProductDependency;
package = 2FE750CA2A87240100723EAE /* XCRemoteSwiftPackageReference "SpeziMockWebService" */;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "830ffa9276e10267881f2697283c2fcd867603fd",
"version" : "7.13.0"
"revision" : "26c898aed8bed13b8a63057ee26500abbbcb8d55",
"version" : "7.13.1"
}
},
{
Expand Down
4 changes: 4 additions & 0 deletions PICS.xcodeproj/xcshareddata/xcschemes/PICS.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@
argument = "--disableFirebase"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--mockTestData"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--showOnboarding"
isEnabled = "NO">
Expand Down
131 changes: 84 additions & 47 deletions PICS/HealthVisulization/HKVisualization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,32 @@ struct HKVisualization: View {
self._presentingAccount = presentingAccount
}

func loadMockData() {
// Load the mock data for testing purposes to the states.
let today = Date()
let sumStatData = [
HKData(date: today, sumValue: 100, avgValue: 0, minValue: 0, maxValue: 0),
HKData(date: today, sumValue: 100, avgValue: 0, minValue: 0, maxValue: 0)
]
let minMaxAvgStatData = [
HKData(date: today, sumValue: 0, avgValue: 50, minValue: 1, maxValue: 100)
]
if self.stepData.isEmpty {
self.stepData = sumStatData
self.heartRateScatterData = sumStatData
self.oxygenSaturationScatterData = sumStatData
self.heartRateData = minMaxAvgStatData
self.oxygenSaturationData = minMaxAvgStatData
}
}

func readAllHKData(ensureUpdate: Bool = false) {
if FeatureFlags.mockTestData {
// Use the mockData directly and no need to query HK data.
loadMockData()
return
}

// Generate the dates and predicates for all HealthKit queries.
let startOfToday: Date = Calendar.current.startOfDay(for: Date())
guard let endDate = Calendar.current.date(byAdding: DateComponents(hour: 23, minute: 59, second: 59), to: startOfToday) else {
Expand Down Expand Up @@ -164,26 +189,12 @@ struct HKVisualization: View {
sortDescriptors: sortDescriptors
) { _, results, error in
guard error == nil else {
print(print("Error retrieving health kit data: \(String(describing: error))"))
print("Error retrieving health kit data: \(String(describing: error))")
return
}
if let results = results {
// Retrieve quantity value and time for each data point.
var collectedData: [HKData] = []
for result in results {
guard let result: HKQuantitySample = result as? HKQuantitySample else {
print("Unexpected heart rate sample type received.")
continue
}
var value = -1.0 // To be replaced below.
if quantityTypeIDF == HKQuantityTypeIdentifier.oxygenSaturation {
value = result.quantity.doubleValue(for: HKUnit.percent()) * 100
} else if quantityTypeIDF == HKQuantityTypeIdentifier.heartRate {
value = result.quantity.doubleValue(for: HKUnit(from: "count/min"))
}
let date = result.startDate
collectedData.append(HKData(date: date, sumValue: value, avgValue: -1.0, minValue: -1.0, maxValue: -1.0))
}
let collectedData = parseSampleQueryData(results: results, quantityTypeIDF: quantityTypeIDF)
if quantityTypeIDF == HKQuantityTypeIdentifier.oxygenSaturation {
self.oxygenSaturationScatterData = collectedData
} else if quantityTypeIDF == HKQuantityTypeIdentifier.heartRate {
Expand Down Expand Up @@ -224,7 +235,7 @@ struct HKVisualization: View {
}
query.initialResultsHandler = { _, results, error in
guard error == nil else {
print(print("Error retrieving health kit data: \(String(describing: error))"))
print("Error retrieving health kit data: \(String(describing: error))")
return
}
if let results = results {
Expand All @@ -243,25 +254,8 @@ struct HKVisualization: View {
var allData: [HKData] = []
// Enumerate over all the statistics objects between the start and end dates.
results.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
let date = statistics.endDate
var curSum = 0.0
var curMax = 0.0
var curAvg = 0.0
var curMin = 0.0
if let quantity = statistics.sumQuantity() {
curSum = parseValue(quantity: quantity, quantityTypeIDF: quantityTypeIDF)
}
if let quantity = statistics.maximumQuantity() {
curMax = parseValue(quantity: quantity, quantityTypeIDF: quantityTypeIDF)
}
if let quantity = statistics.averageQuantity() {
curAvg = parseValue(quantity: quantity, quantityTypeIDF: quantityTypeIDF)
}
if let quantity = statistics.minimumQuantity() {
curMin = parseValue(quantity: quantity, quantityTypeIDF: quantityTypeIDF)
}
if curSum != 0.0 || curMin != 0.0 || curMin != 0.0 || curMax != 0.0 {
allData.append(HKData(date: date, sumValue: curSum, avgValue: curAvg, minValue: curMin, maxValue: curMax))
if let curHKData = parseStat(statistics: statistics, quantityTypeIDF: quantityTypeIDF) {
allData.append(curHKData)
}
}

Expand All @@ -277,21 +271,64 @@ struct HKVisualization: View {
}
}

func parseValue(quantity: HKQuantity, quantityTypeIDF: HKQuantityTypeIdentifier) -> Double {
switch quantityTypeIDF {
case .stepCount:
return quantity.doubleValue(for: .count())
case .oxygenSaturation:
return quantity.doubleValue(for: .percent()) * 100
case .heartRate:
return quantity.doubleValue(for: HKUnit(from: "count/min"))
default:
print("Unexpected quantity received:", quantityTypeIDF)
return -1.0
func parseStat(statistics: HKStatistics, quantityTypeIDF: HKQuantityTypeIdentifier) -> HKData? {
let date = statistics.endDate
var curSum = 0.0
var curMax = 0.0
var curAvg = 0.0
var curMin = 0.0
if let quantity = statistics.sumQuantity() {
curSum = parseValue(quantity: quantity, quantityTypeIDF: quantityTypeIDF)
}
if let quantity = statistics.maximumQuantity() {
curMax = parseValue(quantity: quantity, quantityTypeIDF: quantityTypeIDF)
}
if let quantity = statistics.averageQuantity() {
curAvg = parseValue(quantity: quantity, quantityTypeIDF: quantityTypeIDF)
}
if let quantity = statistics.minimumQuantity() {
curMin = parseValue(quantity: quantity, quantityTypeIDF: quantityTypeIDF)
}
if curSum != 0.0 || curMin != 0.0 || curMin != 0.0 || curMax != 0.0 {
return HKData(date: date, sumValue: curSum, avgValue: curAvg, minValue: curMin, maxValue: curMax)
}
return nil
}
}

func parseValue(quantity: HKQuantity, quantityTypeIDF: HKQuantityTypeIdentifier) -> Double {
switch quantityTypeIDF {
case .stepCount:
return quantity.doubleValue(for: .count())
case .oxygenSaturation:
return quantity.doubleValue(for: .percent()) * 100
case .heartRate:
return quantity.doubleValue(for: HKUnit(from: "count/min"))
default:
print("Unexpected quantity received:", quantityTypeIDF)
return -1.0
}
}

func parseSampleQueryData(results: [HKSample], quantityTypeIDF: HKQuantityTypeIdentifier) -> [HKData] {
// Retrieve quantity value and time for each data point.
var collectedData: [HKData] = []
for result in results {
guard let result: HKQuantitySample = result as? HKQuantitySample else {
print("Unexpected HK Quantity sample received.")
continue
}
var value = -1.0 // To be replaced below.
if quantityTypeIDF == HKQuantityTypeIdentifier.oxygenSaturation {
value = result.quantity.doubleValue(for: HKUnit.percent()) * 100
} else if quantityTypeIDF == HKQuantityTypeIdentifier.heartRate {
value = result.quantity.doubleValue(for: HKUnit(from: "count/min"))
}
let date = result.startDate
collectedData.append(HKData(date: date, sumValue: value, avgValue: -1.0, minValue: -1.0, maxValue: -1.0))
}
return collectedData
}

#if DEBUG
#Preview {
Expand Down
6 changes: 1 addition & 5 deletions PICS/HealthVisulization/HKVisualizationItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,7 @@ struct HKVisualizationItem: View {

@ViewBuilder
func getLollipop(lineX: CGFloat, lineHeight: CGFloat, geoSizeWidth: CGFloat, elm: HKData) -> some View {
let lollipopBoxWidth: CGFloat = if elm.sumValue > 0 {
100
} else {
200
}
let lollipopBoxWidth: CGFloat = 100
let boxOffset = max(0, min(geoSizeWidth - lollipopBoxWidth, lineX - lollipopBoxWidth / 2))

Rectangle()
Expand Down
2 changes: 2 additions & 0 deletions PICS/SharedContext/FeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ enum FeatureFlags {
#endif
/// Adds a test task to the schedule at the current time
static let testSchedule = CommandLine.arguments.contains("--testSchedule")
/// Defines whether to use the mock data for testing the application. This should only be set to true in UI tests.
static let mockTestData = CommandLine.arguments.contains("--mockTestData")
}
69 changes: 69 additions & 0 deletions PICSTests/HKVizUnitTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// This source file is part of the PICS based on the Stanford Spezi Template Application project
//
// SPDX-FileCopyrightText: 2024 Stanford University
//
// SPDX-License-Identifier: MIT
//

import HealthKit
@testable import PICS
import XCTest


class HKVizUnitTests: XCTestCase {
// Test the parseValue function by manually creating HKQuantity and parse it.
func testHKParseValue() throws {
let stepQuantity = HKQuantity(unit: .count(), doubleValue: 10)
XCTAssertEqual(parseValue(quantity: stepQuantity, quantityTypeIDF: .stepCount), 10)

let osQuantity = HKQuantity(unit: .percent(), doubleValue: 0.9)
XCTAssertEqual(parseValue(quantity: osQuantity, quantityTypeIDF: .oxygenSaturation), 90)

let hrQuantity = HKQuantity(unit: HKUnit(from: "count/min"), doubleValue: 10)
XCTAssertEqual(parseValue(quantity: hrQuantity, quantityTypeIDF: .heartRate), 10)

// Unexpected quantity returns -1.
XCTAssertEqual(parseValue(quantity: hrQuantity, quantityTypeIDF: .bodyMass), -1)
}

// Test updateSampleQueryData function by manually creating HKSample and parse it.
func testHKUpdateSampleQueryData() throws {
// This function only support oxygen saturation and heart rate so we only need to test them.
let date = Date()
// Testing parsing the oxygen saturation samples.
let osQuantityType = HKQuantityType(.oxygenSaturation)
var osSamples: [HKSample] = []
let pcts = [0.9, 0.95, 0.98]
for val in pcts {
let osQuantity = HKQuantity(unit: .percent(), doubleValue: val)
let osQS: HKSample = HKQuantitySample(type: osQuantityType, quantity: osQuantity, start: date, end: date)
osSamples.append(osQS)
}
var dataParsed = parseSampleQueryData(results: osSamples, quantityTypeIDF: .oxygenSaturation)
for (parsed, pct) in zip(dataParsed, pcts) {
XCTAssertEqual(parsed.date, date)
XCTAssertEqual(parsed.sumValue, pct * 100)
XCTAssertEqual(parsed.avgValue, -1)
XCTAssertEqual(parsed.minValue, -1)
XCTAssertEqual(parsed.minValue, -1)
}
// Testing parsing the heart rate samples.
let hrQuantityType = HKQuantityType(.heartRate)
var hrSamples: [HKSample] = []
let hrs = [50, 55, 60]
for val in hrs {
let hrQuantity = HKQuantity(unit: HKUnit(from: "count/min"), doubleValue: Double(val))
let hrQS: HKSample = HKQuantitySample(type: hrQuantityType, quantity: hrQuantity, start: date, end: date)
hrSamples.append(hrQS)
}
dataParsed = parseSampleQueryData(results: hrSamples, quantityTypeIDF: .heartRate)
for (parsed, val) in zip(dataParsed, hrs) {
XCTAssertEqual(parsed.date, date)
XCTAssertEqual(parsed.sumValue, Double(val))
XCTAssertEqual(parsed.avgValue, -1)
XCTAssertEqual(parsed.minValue, -1)
XCTAssertEqual(parsed.minValue, -1)
}
}
}
Loading

0 comments on commit a7c6c16

Please sign in to comment.