First Test
Posted on Mon 18 April 2016 in TDDA
In the last post,
I presented some code for extracting (some of) the data from the XML
file exported by the Apple Health
app on iOS, but—almost
comically, given this blog's theme—omitted to include any tests.
This post and the next couple (in quick succession) will aim to fix that.
This post begins to remedy that by writing a single "reference" test. To recap: a reference test is a test that tests a whole analytical process, checking that the known inputs produce the expected outputs. So far, our analytical process is quite small, consisting only of data extraction, but this will still prove very worthwhile.
Dogma
While the mainstream TDD dogma states that tests should be written before the code, it is far from uncommon to write them afterwards, and in the context of test-driven data analysis I maintain that this is usually preferable. Regardless, when you find yourself in a situation in which you have written some code and possess any reasonable level of belief that it might be right,1 an excellent starting point is simply to capture the input(s) that you have already used, together with the output that it generates, and write a test that checks that the input you provided produces the expected output. That's exactly the procedure I advocated for TDDA, and that's how we shall start here.
Test Data
The only flies in the ointment in this case are
-
the input data I used initially was quite large (5.5MB compressed; 109MB uncompressed), leading to quite a slow test;
-
the data is somewhat personal.
For both these reasons, I have decided to reduce it so that it will be more manageable, run more quickly, and be more suitable for public sharing.
So I cut down the data to contain only the DTD header, the Me
record, ten StepCount
records, and five DistanceWalkingRunning
records. That results in a small, valid XML file (under 7K)
containing exactly 100 lines. It's in the testdata
subdirectory of
the repository, and if I run it (which you probably don't want do, at
least in situ, as that will trample over the reference output), the
following output is produced:
$ python applehealthdata/applehealthdata.py testdata/export6s3sample.xml
Reading data from testdata/export6s3sample.xml . . . done
Tags:
ActivitySummary: 2
ExportDate: 1
Me: 1
Record: 15
Fields:
HKCharacteristicTypeIdentifierBiologicalSex: 1
HKCharacteristicTypeIdentifierBloodType: 1
HKCharacteristicTypeIdentifierDateOfBirth: 1
HKCharacteristicTypeIdentifierFitzpatrickSkinType: 1
activeEnergyBurned: 2
activeEnergyBurnedGoal: 2
activeEnergyBurnedUnit: 2
appleExerciseTime: 2
appleExerciseTimeGoal: 2
appleStandHours: 2
appleStandHoursGoal: 2
creationDate: 15
dateComponents: 2
endDate: 15
sourceName: 15
startDate: 15
type: 15
unit: 15
value: 16
Record types:
DistanceWalkingRunning: 5
StepCount: 10
Opening /Users/njr/qs/testdata/StepCount.csv for writing
Opening /Users/njr/qs/testdata/DistanceWalkingRunning.csv for writing
Written StepCount data.
Written DistanceWalkingRunning data.
The two CSV files it writes, which are also in the testdata
subdirectory
in the repository, are as follows:
$ cat testdata/StepCount.csv
sourceName,sourceVersion,device,type,unit,creationDate,startDate,endDate,value
"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:27:54 +0100,2014-09-13 10:27:59 +0100,329
"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:34:09 +0100,2014-09-13 10:34:14 +0100,283
"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:39:29 +0100,2014-09-13 10:39:34 +0100,426
"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:45:36 +0100,2014-09-13 10:45:41 +0100,61
"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:51:16 +0100,2014-09-13 10:51:21 +0100,10
"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:57:40 +0100,2014-09-13 10:57:45 +0100,200
"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:03:00 +0100,2014-09-13 11:03:05 +0100,390
"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:08:10 +0100,2014-09-13 11:08:15 +0100,320
"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:27:22 +0100,2014-09-13 11:27:27 +0100,216
"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:33:24 +0100,2014-09-13 11:33:29 +0100,282
and
$ cat testdata/DistanceWalkingRunning.csv
sourceName,sourceVersion,device,type,unit,creationDate,startDate,endDate,value
"Health",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:41:28 +0100,2014-09-20 10:41:30 +0100,0.00288
"Health",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:41:30 +0100,2014-09-20 10:41:33 +0100,0.00284
"Health",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:41:33 +0100,2014-09-20 10:41:36 +0100,0.00142
"Health",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:43:54 +0100,2014-09-20 10:43:56 +0100,0.00639
"Health",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:43:59 +0100,2014-09-20 10:44:01 +0100,0.0059
Reference Test
The code for a single reference test is below. It's slightly verbose, because it tries to use sensible locations for everything, but not complex.
As before, you can get the code from Github with
$ git clone https://github.com/tdda/applehealthdata.git
or if you have pulled it previously, you can update it with
$ git pull
This version of the code is tagged with v1.1
, so if it has been updated
by the time you read this, get that version with
$ git checkout v1.1
Here is the code:
"""
testapplehealthdata.py: tests for the applehealthdata.py
Copyright (c) 2016 Nicholas J. Radcliffe
Licence: MIT
"""
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
import os
import re
import shutil
import sys
import unittest
from applehealthdata import HealthDataExtractor
CLEAN_UP = True
VERBOSE = False
def get_base_dir():
"""
Return the directory containing this test file,
which will (normally) be the applyhealthdata directory
also containing the testdata dir.
"""
return os.path.split(os.path.abspath(__file__))[0]
def get_testdata_dir():
"""Return the full path to the testdata directory"""
return os.path.join(get_base_dir(), 'testdata')
def get_tmp_dir():
"""Return the full path to the tmp directory"""
return os.path.join(get_base_dir(), 'tmp')
def remove_any_tmp_dir():
"""
Remove the temporary directory if it exists.
Returns its location either way.
"""
tmp_dir = get_tmp_dir()
if os.path.exists(tmp_dir):
shutil.rmtree(tmp_dir)
return tmp_dir
def make_tmp_dir():
"""
Remove any existing tmp directory.
Create empty tmp direcory.
Return the location of the tmp dir.
"""
tmp_dir = remove_any_tmp_dir()
os.mkdir(tmp_dir)
return tmp_dir
def copy_test_data():
"""
Copy the test data export6s3sample.xml from testdata directory
to tmp directory.
"""
tmp_dir = make_tmp_dir()
name = 'export6s3sample.xml'
in_xml_file = os.path.join(get_testdata_dir(), name)
out_xml_file = os.path.join(get_tmp_dir(), name)
shutil.copyfile(in_xml_file, out_xml_file)
return out_xml_file
class TestAppleHealthDataExtractor(unittest.TestCase):
@classmethod
def tearDownClass(cls):
"""Clean up by removing the tmp directory, if it exists."""
if CLEAN_UP:
remove_any_tmp_dir()
def check_file(self, filename):
expected_output = os.path.join(get_testdata_dir(), filename)
actual_output = os.path.join(get_tmp_dir(), filename)
with open(expected_output) as f:
expected = f.read()
with open(actual_output) as f:
actual = f.read()
self.assertEqual(expected, actual)
def test_tiny_fixed_extraction(self):
path = copy_test_data()
data = HealthDataExtractor(path, verbose=VERBOSE)
data.extract()
self.check_file('StepCount.csv')
self.check_file('DistanceWalkingRunning.csv')
if __name__ == '__main__':
unittest.main()
Running the Test
This is what I get if I run it:
$ python testapplehealthdata.py
.
----------------------------------------------------------------------
Ran 1 test in 0.007s
OK
$
That's encouraging, but not particularly informative. If we change
the value of VERBOSE
at the top of the test file to True
, we see
slightly more reassuring output:
$ python testapplehealthdata.py
Reading data from /Users/njr/qs/applehealthdata/tmp/export6s3sample.xml . . . done
Opening /Users/njr/qs/applehealthdata/tmp/StepCount.csv for writing
Opening /Users/njr/qs/applehealthdata/tmp/DistanceWalkingRunning.csv for writing
Written StepCount data.
Written DistanceWalkingRunning data.
.
----------------------------------------------------------------------
Ran 1 test in 0.006s
NOTE: The tearDownClass
method is a special Python class method
that the unit testing framework runs after executing all the tests in
the class, regardless of whether they pass, fail or produce errors. I
use it to remove the tmp
directory containing any test output, which
is normally good practice. In a later post, we'll either modify this
to leave the output around if any tests fail, or make some other
change to make it easier to diagnose what's gone wrong. In the
meantime, if you change the value of CLEAN_UP
, towards the top of
the code, to False
, it will leave the tmp
directory around,
allowing you to examine the files it has produced.
Overview
The test itself is in the 5-line method test_tiny_fixed_extraction
.
Here's what the five lines do:
-
Copy the input XML file from the
testdata
directory to thetmp
directory. The Github repository contains the 100-line input XML file together with the expected output in thetestdata
subdirectory. Because the data extractor writes the CSV files next to the input data, the cleanest thing for us to do is to take a copy of the input data, write it into a new directory (applehealthdata/tmp
) and also to use that directory as the location for the output CSV files. Thecopy_test_data
function removes any existingtmp
directory it finds, creates a fresh one, copies the input test data into it and returns the path to the test data file. The only "magic" here is that theget_base_dir
function figures out where to locate everything by using__file__
, which is the location of the source file being executed by Python. -
Create a
HealthDataExtractor
object, using the location of the copy of the input data returned bycopy_test_data()
. Note that it setsverbose
toFalse
, making the test silent, and allowing the line of dots from a successful test run (in this case, a single dot) to be presented without interruption. -
Extract the data. This writes two output files to the
applehealthdata/tmp
directory. -
Check that the contents of
tmp/StepCount.csv
match the reference output intestdata/StepCount.csv
. -
Check that the contents of
tmp/DistanceWalkingRunning.csv
match the reference output intestdata/DistanceWalkingRunning.csv
.
Write-Test-Break-Run-Repair-Rerun
In cases in which the tests are written after the code, it's important
to check that they really are running correctly. My usual approach to
that is to write the test, and if appears to pass first
time,2 to break it deliberately to verify that it fails
when it should, before repairing it. In this case, the simplest way to
break the test is to change the reference data temporarily. This
will also reveal a weakness in the current check_file
function.
We'll try three variants of this:
Variant 1: Break the StepCount.csv reference data.
First, I add a Z
to the end of testdata/StepCount.csv
and re-run
the tests:
$ python testapplehealthdata.py
F
======================================================================
FAIL: test_tiny_fixed_extraction (__main__.TestAppleHealthDataExtractor)
----------------------------------------------------------------------
Traceback (most recent call last):
File "testapplehealthdata.py", line 98, in test_tiny_fixed_extraction
self.check_file('StepCount.csv')
File "testapplehealthdata.py", line 92, in check_file
self.assertEqual(expected, actual)
AssertionError: 'sourceName,sourceVersion,device,type,unit,creationDate,startDate,endDate,value\n"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:27:54 +0100,2014-09-13 10:27:59 +0100,329\n"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:34:09 +0100,2014-09-13 10:34:14 +0100,283\n"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:39:29 +0100,2014-09-13 10:39:34 +0100,426\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:45:36 +0100,2014-09-13 10:45:41 +0100,61\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:51:16 +0100,2014-09-13 10:51:21 +0100,10\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:57:40 +0100,2014-09-13 10:57:45 +0100,200\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:03:00 +0100,2014-09-13 11:03:05 +0100,390\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:08:10 +0100,2014-09-13 11:08:15 +0100,320\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:27:22 +0100,2014-09-13 11:27:27 +0100,216\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:33:24 +0100,2014-09-13 11:33:29 +0100,282\nZ' != 'sourceName,sourceVersion,device,type,unit,creationDate,startDate,endDate,value\n"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:27:54 +0100,2014-09-13 10:27:59 +0100,329\n"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:34:09 +0100,2014-09-13 10:34:14 +0100,283\n"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:39:29 +0100,2014-09-13 10:39:34 +0100,426\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:45:36 +0100,2014-09-13 10:45:41 +0100,61\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:51:16 +0100,2014-09-13 10:51:21 +0100,10\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:57:40 +0100,2014-09-13 10:57:45 +0100,200\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:03:00 +0100,2014-09-13 11:03:05 +0100,390\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:08:10 +0100,2014-09-13 11:08:15 +0100,320\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:27:22 +0100,2014-09-13 11:27:27 +0100,216\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:33:24 +0100,2014-09-13 11:33:29 +0100,282\n'
----------------------------------------------------------------------
Ran 1 test in 0.005s
FAILED (failures=1)
$
That causes the expected failure.
Because, however, we've compared the entire contents of the
two CSV files, it's hard to see what's actually gone wrong.
We'll address this by improving the check_file
method in a later post.
Variant 2: Break the DistanceWalkingRunning.csv reference data.
After restoring the StepCount.csv
data,
I modify the reference testdata/DistanceWalkingRunning.csv
data.
This time, I'll change Health
to Wealth
throughout.
$ python testapplehealthdata.py
F
======================================================================
FAIL: test_tiny_fixed_extraction (__main__.TestAppleHealthDataExtractor)
----------------------------------------------------------------------
Traceback (most recent call last):
File "testapplehealthdata.py", line 99, in test_tiny_fixed_extraction
self.check_file('DistanceWalkingRunning.csv')
File "testapplehealthdata.py", line 92, in check_file
self.assertEqual(expected, actual)
AssertionError: 'sourceName,sourceVersion,device,type,unit,creationDate,startDate,endDate,value\n"Wealth",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:41:28 +0100,2014-09-20 10:41:30 +0100,0.00288\n"Wealth",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:41:30 +0100,2014-09-20 10:41:33 +0100,0.00284\n"Wealth",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:41:33 +0100,2014-09-20 10:41:36 +0100,0.00142\n"Wealth",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:43:54 +0100,2014-09-20 10:43:56 +0100,0.00639\n"Wealth",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:43:59 +0100,2014-09-20 10:44:01 +0100,0.0059\n' != 'sourceName,sourceVersion,device,type,unit,creationDate,startDate,endDate,value\n"Health",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:41:28 +0100,2014-09-20 10:41:30 +0100,0.00288\n"Health",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:41:30 +0100,2014-09-20 10:41:33 +0100,0.00284\n"Health",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:41:33 +0100,2014-09-20 10:41:36 +0100,0.00142\n"Health",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:43:54 +0100,2014-09-20 10:43:56 +0100,0.00639\n"Health",,,"DistanceWalkingRunning","km",2014-09-21 07:08:49 +0100,2014-09-20 10:43:59 +0100,2014-09-20 10:44:01 +0100,0.0059\n'
----------------------------------------------------------------------
Ran 1 test in 0.005s
FAILED (failures=1)
$
The story is very much the same: the test has failed, which is good, but again the source of difference is hard to discern.
Variant 3: Break the input XML Data.
After restoring DistanceWalkingRunning.csv
,
I modify the input XML file.
In this case, I'll just change the first step count to be 330 instead of 329:
$ python testapplehealthdata.py
F
======================================================================
FAIL: test_tiny_fixed_extraction (__main__.TestAppleHealthDataExtractor)
----------------------------------------------------------------------
Traceback (most recent call last):
File "testapplehealthdata.py", line 98, in test_tiny_fixed_extraction
self.check_file('StepCount.csv')
File "testapplehealthdata.py", line 92, in check_file
self.assertEqual(expected, actual)
AssertionError: 'sourceName,sourceVersion,device,type,unit,creationDate,startDate,endDate,value\n"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:27:54 +0100,2014-09-13 10:27:59 +0100,329\n"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:34:09 +0100,2014-09-13 10:34:14 +0100,283\n"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:39:29 +0100,2014-09-13 10:39:34 +0100,426\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:45:36 +0100,2014-09-13 10:45:41 +0100,61\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:51:16 +0100,2014-09-13 10:51:21 +0100,10\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:57:40 +0100,2014-09-13 10:57:45 +0100,200\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:03:00 +0100,2014-09-13 11:03:05 +0100,390\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:08:10 +0100,2014-09-13 11:08:15 +0100,320\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:27:22 +0100,2014-09-13 11:27:27 +0100,216\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:33:24 +0100,2014-09-13 11:33:29 +0100,282\n' != 'sourceName,sourceVersion,device,type,unit,creationDate,startDate,endDate,value\n"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:27:54 +0100,2014-09-13 10:27:59 +0100,330\n"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:34:09 +0100,2014-09-13 10:34:14 +0100,283\n"Health",,,"StepCount","count",2014-09-21 07:08:47 +0100,2014-09-13 10:39:29 +0100,2014-09-13 10:39:34 +0100,426\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:45:36 +0100,2014-09-13 10:45:41 +0100,61\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:51:16 +0100,2014-09-13 10:51:21 +0100,10\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 10:57:40 +0100,2014-09-13 10:57:45 +0100,200\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:03:00 +0100,2014-09-13 11:03:05 +0100,390\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:08:10 +0100,2014-09-13 11:08:15 +0100,320\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:27:22 +0100,2014-09-13 11:27:27 +0100,216\n"Health",,,"StepCount","count",2014-09-21 07:08:48 +0100,2014-09-13 11:33:24 +0100,2014-09-13 11:33:29 +0100,282\n'
----------------------------------------------------------------------
Ran 1 test in 0.005s
FAILED (failures=1)
$
Again, we get the expected failure, and again it's hard to see what it is.
(We really will need to improve check_file
.)
Enough
That's enough for this post. We've successfully added a single "reference" test to the code, which should at least make sure that if we break it during further enhancements, we will notice. It will also check that it is working correctly on other platforms (e.g., yours).
We haven't done anything to check the the CSV files produced are genuinely right beyond the initial eye-balling I did on first extracting the data before. But if we see problems when we start doing proper analysis, it will be easy to correct the expected output to keep the test running. And in the meantime, we'll notice if we make changes to the code that result in different output when it wasn't meant to do so. This is one part of the pragmatic essence of basic TDDA.
We also haven't written any unit tests at all for the extraction code; we'll do that in a later post.