Repository: mapbox/batfish
Pull request #165 (2017-09-05 17:29:06)

More tests
Adding a lot of tests ...

davidtheclark: I'm going to merge what's here because there's no need for a long-standing branch. Key files like `router`, `start`, and `build` are the most ridiculous to test. We might be better off building examples with a checklist for manual QA if those files change ...

Patch:
diff --git a/.travis.yml b/.travis.yml
index 95c7b17..e9caab1 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,7 +4,7 @@ node_js:
 cache:
   directories:
     - node_modules
-before_install:
-  - npm i -g npm@latest
+# before_install:
+#   - npm i -g npm@latest
 before_script:
   - npm run build
diff --git a/flow-typed/errors.js b/flow-typed/errors.js
index c23ff0b..e2b672b 100644
--- a/flow-typed/errors.js
+++ b/flow-typed/errors.js
@@ -2,8 +2,8 @@ declare class WrappedError extends Error {
   originalError: Error
 }
 
-declare class ConfigValidationErrors extends WrappedError {
-  validationErrors: Array<string>
+declare class ConfigValidationError extends WrappedError {
+  messages?: Array<string>
 }
 
 declare class WebpackCompilationError extends WrappedError {
diff --git a/package-lock.json b/package-lock.json
index 5a44231..cac3daf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -682,6 +682,17 @@
             "has-ansi": "2.0.0",
             "strip-ansi": "3.0.1",
             "supports-color": "2.0.0"
+          },
+          "dependencies": {
+            "strip-ansi": {
+              "version": "3.0.1",
+              "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+              "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+              "dev": true,
+              "requires": {
+                "ansi-regex": "2.1.1"
+              }
+            }
           }
         },
         "core-js": {
@@ -744,6 +755,16 @@
             "has-ansi": "2.0.0",
             "strip-ansi": "3.0.1",
             "supports-color": "2.0.0"
+          },
+          "dependencies": {
+            "strip-ansi": {
+              "version": "3.0.1",
+              "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+              "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+              "requires": {
+                "ansi-regex": "2.1.1"
+              }
+            }
           }
         }
       }
@@ -1534,6 +1555,16 @@
             "has-ansi": "2.0.0",
             "strip-ansi": "3.0.1",
             "supports-color": "2.0.0"
+          },
+          "dependencies": {
+            "strip-ansi": {
+              "version": "3.0.1",
+              "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+              "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+              "requires": {
+                "ansi-regex": "2.1.1"
+              }
+            }
           }
         },
         "core-js": {
@@ -2279,6 +2310,16 @@
         "string-width": "1.0.2",
         "strip-ansi": "3.0.1",
         "wrap-ansi": "2.1.0"
+      },
+      "dependencies": {
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "requires": {
+            "ansi-regex": "2.1.1"
+          }
+        }
       }
     },
     "co": {
@@ -3602,12 +3643,6 @@
             "json-stable-stringify": "1.0.1"
           }
         },
-        "ansi-regex": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
-          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
-          "dev": true
-        },
         "glob": {
           "version": "7.1.2",
           "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
@@ -3631,15 +3666,6 @@
             "argparse": "1.0.9",
             "esprima": "4.0.0"
           }
-        },
-        "strip-ansi": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
-          "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
-          "dev": true,
-          "requires": {
-            "ansi-regex": "3.0.0"
-          }
         }
       }
     },
@@ -4480,12 +4506,14 @@
       "dependencies": {
         "abbrev": {
           "version": "1.1.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.0.tgz",
+          "integrity": "sha1-0FVMIlZjbi9W58LlrRg/hZQo2B8=",
           "optional": true
         },
         "ajv": {
           "version": "4.11.8",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz",
+          "integrity": "sha1-gv+wKynmYq5TvcIK8VlHcGc5xTY=",
           "optional": true,
           "requires": {
             "co": "4.6.0",
@@ -4494,16 +4522,19 @@
         },
         "ansi-regex": {
           "version": "2.1.1",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+          "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
         },
         "aproba": {
           "version": "1.1.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.1.1.tgz",
+          "integrity": "sha1-ldNgDwdxCqDpKYxyatXs8urLq6s=",
           "optional": true
         },
         "are-we-there-yet": {
           "version": "1.1.4",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz",
+          "integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=",
           "optional": true,
           "requires": {
             "delegates": "1.0.0",
@@ -4512,36 +4543,43 @@
         },
         "asn1": {
           "version": "0.2.3",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz",
+          "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=",
           "optional": true
         },
         "assert-plus": {
           "version": "0.2.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz",
+          "integrity": "sha1-104bh+ev/A24qttwIfP+SBAasjQ=",
           "optional": true
         },
         "asynckit": {
           "version": "0.4.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+          "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
           "optional": true
         },
         "aws-sign2": {
           "version": "0.6.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz",
+          "integrity": "sha1-FDQt0428yU0OW4fXY81jYSwOeU8=",
           "optional": true
         },
         "aws4": {
           "version": "1.6.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz",
+          "integrity": "sha1-g+9cqGCysy5KDe7e6MdxudtXRx4=",
           "optional": true
         },
         "balanced-match": {
           "version": "0.4.2",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz",
+          "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg="
         },
         "bcrypt-pbkdf": {
           "version": "1.0.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz",
+          "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=",
           "optional": true,
           "requires": {
             "tweetnacl": "0.14.5"
@@ -4549,21 +4587,24 @@
         },
         "block-stream": {
           "version": "0.0.9",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz",
+          "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=",
           "requires": {
             "inherits": "2.0.3"
           }
         },
         "boom": {
           "version": "2.10.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz",
+          "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=",
           "requires": {
             "hoek": "2.16.3"
           }
         },
         "brace-expansion": {
           "version": "1.1.7",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.7.tgz",
+          "integrity": "sha1-Pv/DxQ4ABTH7cg6v+A8K6O8jz1k=",
           "requires": {
             "balanced-match": "0.4.2",
             "concat-map": "0.0.1"
@@ -4571,44 +4612,53 @@
         },
         "buffer-shims": {
           "version": "1.0.0",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz",
+          "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E="
         },
         "caseless": {
           "version": "0.12.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+          "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=",
           "optional": true
         },
         "co": {
           "version": "4.6.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
+          "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=",
           "optional": true
         },
         "code-point-at": {
           "version": "1.1.0",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+          "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
         },
         "combined-stream": {
           "version": "1.0.5",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz",
+          "integrity": "sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk=",
           "requires": {
             "delayed-stream": "1.0.0"
           }
         },
         "concat-map": {
           "version": "0.0.1",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+          "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
         },
         "console-control-strings": {
           "version": "1.1.0",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+          "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
         },
         "core-util-is": {
           "version": "1.0.2",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+          "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
         },
         "cryptiles": {
           "version": "2.0.5",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz",
+          "integrity": "sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g=",
           "optional": true,
           "requires": {
             "boom": "2.10.1"
@@ -4616,7 +4666,8 @@
         },
         "dashdash": {
           "version": "1.14.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
+          "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
           "optional": true,
           "requires": {
             "assert-plus": "1.0.0"
@@ -4624,14 +4675,16 @@
           "dependencies": {
             "assert-plus": {
               "version": "1.0.0",
-              "bundled": true,
+              "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+              "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
               "optional": true
             }
           }
         },
         "debug": {
           "version": "2.6.8",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz",
+          "integrity": "sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=",
           "optional": true,
           "requires": {
             "ms": "2.0.0"
@@ -4639,21 +4692,25 @@
         },
         "deep-extend": {
           "version": "0.4.2",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.4.2.tgz",
+          "integrity": "sha1-SLaZwn4zS/ifEIkr5DL25MfTSn8=",
           "optional": true
         },
         "delayed-stream": {
           "version": "1.0.0",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+          "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
         },
         "delegates": {
           "version": "1.0.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+          "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
           "optional": true
         },
         "ecc-jsbn": {
           "version": "0.1.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz",
+          "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=",
           "optional": true,
           "requires": {
             "jsbn": "0.1.1"
@@ -4661,21 +4718,25 @@
         },
         "extend": {
           "version": "3.0.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
+          "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=",
           "optional": true
         },
         "extsprintf": {
           "version": "1.0.2",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.2.tgz",
+          "integrity": "sha1-4QgOBljjALBilJkMxw4VAiNf1VA="
         },
         "forever-agent": {
           "version": "0.6.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
+          "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=",
           "optional": true
         },
         "form-data": {
           "version": "2.1.4",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz",
+          "integrity": "sha1-M8GDrPGTJ27KqYFDpp6Uv+4XUNE=",
           "optional": true,
           "requires": {
             "asynckit": "0.4.0",
@@ -4685,11 +4746,13 @@
         },
         "fs.realpath": {
           "version": "1.0.0",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+          "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
         },
         "fstream": {
           "version": "1.0.11",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz",
+          "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=",
           "requires": {
             "graceful-fs": "4.1.11",
             "inherits": "2.0.3",
@@ -4699,7 +4762,8 @@
         },
         "fstream-ignore": {
           "version": "1.0.5",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/fstream-ignore/-/fstream-ignore-1.0.5.tgz",
+          "integrity": "sha1-nDHa40dnAY/h0kmyTa2mfQktoQU=",
           "optional": true,
           "requires": {
             "fstream": "1.0.11",
@@ -4709,7 +4773,8 @@
         },
         "gauge": {
           "version": "2.7.4",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+          "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
           "optional": true,
           "requires": {
             "aproba": "1.1.1",
@@ -4724,7 +4789,8 @@
         },
         "getpass": {
           "version": "0.1.7",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
+          "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
           "optional": true,
           "requires": {
             "assert-plus": "1.0.0"
@@ -4732,14 +4798,16 @@
           "dependencies": {
             "assert-plus": {
               "version": "1.0.0",
-              "bundled": true,
+              "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+              "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
               "optional": true
             }
           }
         },
         "glob": {
           "version": "7.1.2",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
+          "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
           "requires": {
             "fs.realpath": "1.0.0",
             "inflight": "1.0.6",
@@ -4751,16 +4819,19 @@
         },
         "graceful-fs": {
           "version": "4.1.11",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
+          "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg="
         },
         "har-schema": {
           "version": "1.0.5",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-1.0.5.tgz",
+          "integrity": "sha1-0mMTX0MwfALGAq/I/pWXDAFRNp4=",
           "optional": true
         },
         "har-validator": {
           "version": "4.2.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-4.2.1.tgz",
+          "integrity": "sha1-M0gdDxu/9gDdID11gSpqX7oALio=",
           "optional": true,
           "requires": {
             "ajv": "4.11.8",
@@ -4769,12 +4840,14 @@
         },
         "has-unicode": {
           "version": "2.0.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+          "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
           "optional": true
         },
         "hawk": {
           "version": "3.1.3",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz",
+          "integrity": "sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ=",
           "optional": true,
           "requires": {
             "boom": "2.10.1",
@@ -4785,11 +4858,13 @@
         },
         "hoek": {
           "version": "2.16.3",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz",
+          "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0="
         },
         "http-signature": {
           "version": "1.1.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz",
+          "integrity": "sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8=",
           "optional": true,
           "requires": {
             "assert-plus": "0.2.0",
@@ -4799,7 +4874,8 @@
         },
         "inflight": {
           "version": "1.0.6",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+          "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
           "requires": {
             "once": "1.4.0",
             "wrappy": "1.0.2"
@@ -4807,37 +4883,44 @@
         },
         "inherits": {
           "version": "2.0.3",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+          "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
         },
         "ini": {
           "version": "1.3.4",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.4.tgz",
+          "integrity": "sha1-BTfLedr1m1mhpRff9wbIbsA5Fi4=",
           "optional": true
         },
         "is-fullwidth-code-point": {
           "version": "1.0.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+          "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
           "requires": {
             "number-is-nan": "1.0.1"
           }
         },
         "is-typedarray": {
           "version": "1.0.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+          "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=",
           "optional": true
         },
         "isarray": {
           "version": "1.0.0",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+          "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
         },
         "isstream": {
           "version": "0.1.2",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
+          "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=",
           "optional": true
         },
         "jodid25519": {
           "version": "1.0.2",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/jodid25519/-/jodid25519-1.0.2.tgz",
+          "integrity": "sha1-BtSRIlUJNBlHfUJWM2BuDpB4KWc=",
           "optional": true,
           "requires": {
             "jsbn": "0.1.1"
@@ -4845,17 +4928,20 @@
         },
         "jsbn": {
           "version": "0.1.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
+          "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=",
           "optional": true
         },
         "json-schema": {
           "version": "0.2.3",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
+          "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=",
           "optional": true
         },
         "json-stable-stringify": {
           "version": "1.0.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz",
+          "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=",
           "optional": true,
           "requires": {
             "jsonify": "0.0.0"
@@ -4863,17 +4949,20 @@
         },
         "json-stringify-safe": {
           "version": "5.0.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+          "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=",
           "optional": true
         },
         "jsonify": {
           "version": "0.0.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
+          "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=",
           "optional": true
         },
         "jsprim": {
           "version": "1.4.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.0.tgz",
+          "integrity": "sha1-o7h+QCmNjDgFUtjMdiigu5WiKRg=",
           "optional": true,
           "requires": {
             "assert-plus": "1.0.0",
@@ -4884,48 +4973,56 @@
           "dependencies": {
             "assert-plus": {
               "version": "1.0.0",
-              "bundled": true,
+              "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+              "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
               "optional": true
             }
           }
         },
         "mime-db": {
           "version": "1.27.0",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.27.0.tgz",
+          "integrity": "sha1-gg9XIpa70g7CXtVeW13oaeVDbrE="
         },
         "mime-types": {
           "version": "2.1.15",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz",
+          "integrity": "sha1-pOv1BkCUVpI3uM9wBGd20J/JKu0=",
           "requires": {
             "mime-db": "1.27.0"
           }
         },
         "minimatch": {
           "version": "3.0.4",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+          "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
           "requires": {
             "brace-expansion": "1.1.7"
           }
         },
         "minimist": {
           "version": "0.0.8",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
+          "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
         },
         "mkdirp": {
           "version": "0.5.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+          "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
           "requires": {
             "minimist": "0.0.8"
           }
         },
         "ms": {
           "version": "2.0.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
           "optional": true
         },
         "node-pre-gyp": {
           "version": "0.6.36",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.6.36.tgz",
+          "integrity": "sha1-22BBEst04NR3VU6bUFsXq936t4Y=",
           "optional": true,
           "requires": {
             "mkdirp": "0.5.1",
@@ -4941,7 +5038,8 @@
         },
         "nopt": {
           "version": "4.0.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz",
+          "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
           "optional": true,
           "requires": {
             "abbrev": "1.1.0",
@@ -4950,7 +5048,8 @@
         },
         "npmlog": {
           "version": "4.1.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.0.tgz",
+          "integrity": "sha512-ocolIkZYZt8UveuiDS0yAkkIjid1o7lPG8cYm05yNYzBn8ykQtaiPMEGp8fY9tKdDgm8okpdKzkvu1y9hUYugA==",
           "optional": true,
           "requires": {
             "are-we-there-yet": "1.1.4",
@@ -4961,38 +5060,45 @@
         },
         "number-is-nan": {
           "version": "1.0.1",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+          "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
         },
         "oauth-sign": {
           "version": "0.8.2",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz",
+          "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=",
           "optional": true
         },
         "object-assign": {
           "version": "4.1.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+          "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
           "optional": true
         },
         "once": {
           "version": "1.4.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+          "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
           "requires": {
             "wrappy": "1.0.2"
           }
         },
         "os-homedir": {
           "version": "1.0.2",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+          "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
           "optional": true
         },
         "os-tmpdir": {
           "version": "1.0.2",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+          "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
           "optional": true
         },
         "osenv": {
           "version": "0.1.4",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.4.tgz",
+          "integrity": "sha1-Qv5tWVPfBsgGS+bxdsPQWqqjRkQ=",
           "optional": true,
           "requires": {
             "os-homedir": "1.0.2",
@@ -5001,30 +5107,36 @@
         },
         "path-is-absolute": {
           "version": "1.0.1",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+          "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
         },
         "performance-now": {
           "version": "0.2.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-0.2.0.tgz",
+          "integrity": "sha1-M+8wxcd9TqIcWlOGnZG1bY8lVeU=",
           "optional": true
         },
         "process-nextick-args": {
           "version": "1.0.7",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz",
+          "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M="
         },
         "punycode": {
           "version": "1.4.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
+          "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=",
           "optional": true
         },
         "qs": {
           "version": "6.4.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz",
+          "integrity": "sha1-E+JtKK1rD/qpExLNO/cI7TUecjM=",
           "optional": true
         },
         "rc": {
           "version": "1.2.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.1.tgz",
+          "integrity": "sha1-LgPo5C7kULjLPc5lvhv4l04d/ZU=",
           "optional": true,
           "requires": {
             "deep-extend": "0.4.2",
@@ -5035,14 +5147,16 @@
           "dependencies": {
             "minimist": {
               "version": "1.2.0",
-              "bundled": true,
+              "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+              "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
               "optional": true
             }
           }
         },
         "readable-stream": {
           "version": "2.2.9",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.9.tgz",
+          "integrity": "sha1-z3jsb0ptHrQ9JkiMrJfwQudLf8g=",
           "requires": {
             "buffer-shims": "1.0.0",
             "core-util-is": "1.0.2",
@@ -5055,7 +5169,8 @@
         },
         "request": {
           "version": "2.81.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/request/-/request-2.81.0.tgz",
+          "integrity": "sha1-xpKJRqDgbF+Nb4qTM0af/aRimKA=",
           "optional": true,
           "requires": {
             "aws-sign2": "0.6.0",
@@ -5084,33 +5199,39 @@
         },
         "rimraf": {
           "version": "2.6.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.1.tgz",
+          "integrity": "sha1-wjOOxkPfeht/5cVPqG9XQopV8z0=",
           "requires": {
             "glob": "7.1.2"
           }
         },
         "safe-buffer": {
           "version": "5.0.1",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.0.1.tgz",
+          "integrity": "sha1-0mPKVGls2KMGtcplUekt5XkY++c="
         },
         "semver": {
           "version": "5.3.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
+          "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=",
           "optional": true
         },
         "set-blocking": {
           "version": "2.0.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+          "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
           "optional": true
         },
         "signal-exit": {
           "version": "3.0.2",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
+          "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
           "optional": true
         },
         "sntp": {
           "version": "1.0.9",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz",
+          "integrity": "sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg=",
           "optional": true,
           "requires": {
             "hoek": "2.16.3"
@@ -5118,7 +5239,8 @@
         },
         "sshpk": {
           "version": "1.13.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.0.tgz",
+          "integrity": "sha1-/yo+T9BEl1Vf7Zezmg/YL6+zozw=",
           "optional": true,
           "requires": {
             "asn1": "0.2.3",
@@ -5134,21 +5256,24 @@
           "dependencies": {
             "assert-plus": {
               "version": "1.0.0",
-              "bundled": true,
+              "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+              "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
               "optional": true
             }
           }
         },
         "string_decoder": {
           "version": "1.0.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.1.tgz",
+          "integrity": "sha1-YuIA8DmVWmgQ2N8KM//A8BNmLZg=",
           "requires": {
             "safe-buffer": "5.0.1"
           }
         },
         "string-width": {
           "version": "1.0.2",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+          "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
           "requires": {
             "code-point-at": "1.1.0",
             "is-fullwidth-code-point": "1.0.0",
@@ -5157,24 +5282,28 @@
         },
         "stringstream": {
           "version": "0.0.5",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz",
+          "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=",
           "optional": true
         },
         "strip-ansi": {
           "version": "3.0.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
           "requires": {
             "ansi-regex": "2.1.1"
           }
         },
         "strip-json-comments": {
           "version": "2.0.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+          "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
           "optional": true
         },
         "tar": {
           "version": "2.2.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz",
+          "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=",
           "requires": {
             "block-stream": "0.0.9",
             "fstream": "1.0.11",
@@ -5183,7 +5312,8 @@
         },
         "tar-pack": {
           "version": "3.4.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/tar-pack/-/tar-pack-3.4.0.tgz",
+          "integrity": "sha1-I74tf2cagzk3bL2wuP4/3r8xeYQ=",
           "optional": true,
           "requires": {
             "debug": "2.6.8",
@@ -5198,7 +5328,8 @@
         },
         "tough-cookie": {
           "version": "2.3.2",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz",
+          "integrity": "sha1-8IH3bkyFcg5sN6X6ztc3FQ2EByo=",
           "optional": true,
           "requires": {
             "punycode": "1.4.1"
@@ -5206,7 +5337,8 @@
         },
         "tunnel-agent": {
           "version": "0.6.0",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+          "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
           "optional": true,
           "requires": {
             "safe-buffer": "5.0.1"
@@ -5214,26 +5346,31 @@
         },
         "tweetnacl": {
           "version": "0.14.5",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+          "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=",
           "optional": true
         },
         "uid-number": {
           "version": "0.0.6",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz",
+          "integrity": "sha1-DqEOgDXo61uOREnwbaHHMGY7qoE=",
           "optional": true
         },
         "util-deprecate": {
           "version": "1.0.2",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+          "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
         },
         "uuid": {
           "version": "3.0.1",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz",
+          "integrity": "sha1-ZUS7ot/ajBzxfmKaOjBeK7H+5sE=",
           "optional": true
         },
         "verror": {
           "version": "1.3.6",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/verror/-/verror-1.3.6.tgz",
+          "integrity": "sha1-z/XfEpRtKX0rqu+qJoniW+AcAFw=",
           "optional": true,
           "requires": {
             "extsprintf": "1.0.2"
@@ -5241,7 +5378,8 @@
         },
         "wide-align": {
           "version": "1.1.2",
-          "bundled": true,
+          "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.2.tgz",
+          "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==",
           "optional": true,
           "requires": {
             "string-width": "1.0.2"
@@ -5249,7 +5387,8 @@
         },
         "wrappy": {
           "version": "1.0.2",
-          "bundled": true
+          "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+          "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
         }
       }
     },
@@ -6060,12 +6199,6 @@
         "through": "2.3.8"
       },
       "dependencies": {
-        "ansi-regex": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
-          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
-          "dev": true
-        },
         "is-fullwidth-code-point": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
@@ -6081,15 +6214,6 @@
             "is-fullwidth-code-point": "2.0.0",
             "strip-ansi": "4.0.0"
           }
-        },
-        "strip-ansi": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
-          "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
-          "dev": true,
-          "requires": {
-            "ansi-regex": "3.0.0"
-          }
         }
       }
     },
@@ -6611,12 +6735,6 @@
         "jest-cli": "21.0.1"
       },
       "dependencies": {
-        "ansi-regex": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
-          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
-          "dev": true
-        },
         "camelcase": {
           "version": "3.0.0",
           "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz",
@@ -6680,15 +6798,6 @@
           "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
           "dev": true
         },
-        "strip-ansi": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
-          "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
-          "dev": true,
-          "requires": {
-            "ansi-regex": "3.0.0"
-          }
-        },
         "yargs": {
           "version": "7.1.0",
           "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz",
@@ -7402,6 +7511,15 @@
               }
             }
           }
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "2.1.1"
+          }
         }
       }
     },
@@ -7457,6 +7575,15 @@
           "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
           "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
           "dev": true
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "2.1.1"
+          }
         }
       }
     },
@@ -7516,6 +7643,15 @@
           "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
           "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
           "dev": true
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "2.1.1"
+          }
         }
       }
     },
@@ -7584,6 +7720,15 @@
             "exit-hook": "1.1.1",
             "onetime": "1.1.0"
           }
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "2.1.1"
+          }
         }
       }
     },
@@ -7965,6 +8110,15 @@
             "strip-ansi": "3.0.1",
             "supports-color": "2.0.0"
           }
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "2.1.1"
+          }
         }
       }
     },
@@ -8880,6 +9034,15 @@
             "exit-hook": "1.1.1",
             "onetime": "1.1.0"
           }
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "2.1.1"
+          }
         }
       }
     },
@@ -9296,6 +9459,14 @@
             "supports-color": "2.0.0"
           },
           "dependencies": {
+            "strip-ansi": {
+              "version": "3.0.1",
+              "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+              "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+              "requires": {
+                "ansi-regex": "2.1.1"
+              }
+            },
             "supports-color": {
               "version": "2.0.0",
               "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
@@ -9365,6 +9536,14 @@
             "supports-color": "2.0.0"
           },
           "dependencies": {
+            "strip-ansi": {
+              "version": "3.0.1",
+              "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+              "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+              "requires": {
+                "ansi-regex": "2.1.1"
+              }
+            },
             "supports-color": {
               "version": "2.0.0",
               "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
@@ -9419,6 +9598,14 @@
             "supports-color": "2.0.0"
           },
           "dependencies": {
+            "strip-ansi": {
+              "version": "3.0.1",
+              "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+              "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+              "requires": {
+                "ansi-regex": "2.1.1"
+              }
+            },
             "supports-color": {
               "version": "2.0.0",
               "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
@@ -9841,6 +10028,39 @@
         "shallowequal": "1.0.2"
       }
     },
+    "react-test-renderer": {
+      "version": "15.6.1",
+      "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-15.6.1.tgz",
+      "integrity": "sha1-Am9KW7VVJmH9LMS7zQ1LyKNev34=",
+      "dev": true,
+      "requires": {
+        "fbjs": "0.8.14",
+        "object-assign": "4.1.1"
+      },
+      "dependencies": {
+        "fbjs": {
+          "version": "0.8.14",
+          "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.14.tgz",
+          "integrity": "sha1-0dviviVMNakeCfMfnNUKQLKg7Rw=",
+          "dev": true,
+          "requires": {
+            "core-js": "1.2.7",
+            "isomorphic-fetch": "2.2.1",
+            "loose-envify": "1.3.1",
+            "object-assign": "4.1.1",
+            "promise": "7.3.1",
+            "setimmediate": "1.0.5",
+            "ua-parser-js": "0.7.14"
+          }
+        },
+        "object-assign": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+          "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
+          "dev": true
+        }
+      }
+    },
     "read-pkg": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz",
@@ -10219,6 +10439,14 @@
           "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
           "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
         },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "requires": {
+            "ansi-regex": "2.1.1"
+          }
+        },
         "utila": {
           "version": "0.3.3",
           "resolved": "https://registry.npmjs.org/utila/-/utila-0.3.3.tgz",
@@ -10837,6 +11065,11 @@
         }
       }
     },
+    "source-list-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.0.tgz",
+      "integrity": "sha512-I2UmuJSRr/T8jisiROLU3A3ltr+swpniSmNPI4Ml3ZCX6tVnDsuZzK7F2hl5jTqbZBWCEKlj5HRQiPExXLgE8A=="
+    },
     "source-map": {
       "version": "0.4.4",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz",
@@ -10998,6 +11231,17 @@
       "dev": true,
       "requires": {
         "strip-ansi": "3.0.1"
+      },
+      "dependencies": {
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "2.1.1"
+          }
+        }
       }
     },
     "string-width": {
@@ -11008,6 +11252,16 @@
         "code-point-at": "1.1.0",
         "is-fullwidth-code-point": "1.0.0",
         "strip-ansi": "3.0.1"
+      },
+      "dependencies": {
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "requires": {
+            "ansi-regex": "2.1.1"
+          }
+        }
       }
     },
     "string.prototype.padend": {
@@ -11048,11 +11302,18 @@
       "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg="
     },
     "strip-ansi": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
-      "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+      "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
       "requires": {
-        "ansi-regex": "2.1.1"
+        "ansi-regex": "3.0.0"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+        }
       }
     },
     "strip-bom": {
@@ -11124,12 +11385,6 @@
         "string-width": "2.1.1"
       },
       "dependencies": {
-        "ansi-regex": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
-          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
-          "dev": true
-        },
         "chalk": {
           "version": "1.1.3",
           "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
@@ -11159,6 +11414,12 @@
             "strip-ansi": "4.0.0"
           },
           "dependencies": {
+            "ansi-regex": {
+              "version": "3.0.0",
+              "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+              "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
+              "dev": true
+            },
             "strip-ansi": {
               "version": "4.0.0",
               "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
@@ -11169,6 +11430,15 @@
               }
             }
           }
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "2.1.1"
+          }
         }
       }
     },
@@ -11297,6 +11567,16 @@
             "has-ansi": "2.0.0",
             "strip-ansi": "3.0.1",
             "supports-color": "2.0.0"
+          },
+          "dependencies": {
+            "strip-ansi": {
+              "version": "3.0.1",
+              "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+              "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+              "requires": {
+                "ansi-regex": "2.1.1"
+              }
+            }
           }
         }
       }
@@ -11779,6 +12059,16 @@
             "has-ansi": "2.0.0",
             "strip-ansi": "3.0.1",
             "supports-color": "2.0.0"
+          },
+          "dependencies": {
+            "strip-ansi": {
+              "version": "3.0.1",
+              "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+              "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+              "requires": {
+                "ansi-regex": "2.1.1"
+              }
+            }
           }
         }
       }
@@ -12076,11 +12366,6 @@
           "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-2.1.0.tgz",
           "integrity": "sha1-opbhf3v658HOT34N5T0pyzIWLfA="
         },
-        "ansi-regex": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
-          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
-        },
         "async": {
           "version": "2.5.0",
           "resolved": "https://registry.npmjs.org/async/-/async-2.5.0.tgz",
@@ -12193,14 +12478,6 @@
             "strip-ansi": "4.0.0"
           }
         },
-        "strip-ansi": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
-          "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
-          "requires": {
-            "ansi-regex": "3.0.0"
-          }
-        },
         "strip-bom": {
           "version": "3.0.0",
           "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -12323,11 +12600,6 @@
         "source-map": "0.5.7"
       },
       "dependencies": {
-        "source-list-map": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.0.tgz",
-          "integrity": "sha512-I2UmuJSRr/T8jisiROLU3A3ltr+swpniSmNPI4Ml3ZCX6tVnDsuZzK7F2hl5jTqbZBWCEKlj5HRQiPExXLgE8A=="
-        },
         "source-map": {
           "version": "0.5.7",
           "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -12432,6 +12704,16 @@
       "requires": {
         "string-width": "1.0.2",
         "strip-ansi": "3.0.1"
+      },
+      "dependencies": {
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "requires": {
+            "ansi-regex": "2.1.1"
+          }
+        }
       }
     },
     "wrapped": {
diff --git a/package.json b/package.json
index 3ef50c9..34e902e 100644
--- a/package.json
+++ b/package.json
@@ -78,6 +78,9 @@
     "presets": [
       "es2015",
       "react"
+    ],
+    "plugins": [
+      "transform-class-properties"
     ]
   },
   "jest": {
@@ -172,7 +175,7 @@
     "babel-cli": "^6.26.0",
     "babel-eslint": "^7.2.3",
     "cpy-cli": "^1.0.1",
-    "enzyme": "^2.8.2",
+    "enzyme": "^2.9.1",
     "enzyme-to-json": "^1.5.1",
     "eslint": "^4.6.1",
     "eslint-plugin-filenames": "^1.2.0",
@@ -191,8 +194,10 @@
     "react": "^15.5.4",
     "react-dom": "^15.5.4",
     "react-helmet": "^5.2.0",
+    "react-test-renderer": "^15.6.1",
     "remark-cli": "^4.0.0",
-    "remark-validate-links": "^7.0.0"
+    "remark-validate-links": "^7.0.0",
+    "strip-ansi": "^4.0.0"
   },
   "optionalDependencies": {},
   "engines": {
diff --git a/src/node/compile-stylesheets.js b/src/node/compile-stylesheets.js
index 64b4274..ec15001 100644
--- a/src/node/compile-stylesheets.js
+++ b/src/node/compile-stylesheets.js
@@ -33,10 +33,10 @@ const urlCache: Map<string, StylesheetData> = new Map();
 function compileStylesheets(
   batfishConfig: BatfishConfiguration,
   // Defaults to batfishConfig.outputDirectory.
-  inputCssOutputDirectory?: string
+  specialCssOutputDirectory?: string
 ): Promise<string | void> {
   const cssOutputDirectory: string =
-    inputCssOutputDirectory || batfishConfig.outputDirectory;
+    specialCssOutputDirectory || batfishConfig.outputDirectory;
 
   return pTry(() => {
     if (_.isEmpty(batfishConfig.stylesheets)) return;
@@ -136,7 +136,8 @@ function compileStylesheets(
         path.join(cssOutputDirectory, constants.BATFISH_CSS_BASENAME),
         '\n'
       );
-      // Since items in stylesheets can be an array, we need to be recursive here.
+      // Since items in stylesheets can be an array (fancy glob),
+      // we need to be recursive here.
       const addItems = list => {
         list.forEach(item => {
           if (Array.isArray(item)) return addItems(item);
diff --git a/src/node/create-webpack-config-base.js b/src/node/create-webpack-config-base.js
index 9440f36..20023d6 100644
--- a/src/node/create-webpack-config-base.js
+++ b/src/node/create-webpack-config-base.js
@@ -209,4 +209,9 @@ function createWebpackConfigBase(
   });
 }
 
+// For tests.
+createWebpackConfigBase._clearCache = () => {
+  cachedConfig = null;
+};
+
 module.exports = createWebpackConfigBase;
diff --git a/src/node/create-webpack-config-client.js b/src/node/create-webpack-config-client.js
index 80b05c0..01f408c 100644
--- a/src/node/create-webpack-config-client.js
+++ b/src/node/create-webpack-config-client.js
@@ -78,7 +78,7 @@ function createWebpackConfigClient(
       new webpack.optimize.CommonsChunkPlugin('manifest'),
       // Define an environment variable for special cases
       new webpack.DefinePlugin({
-        'process.env.DEV_SERVER': options && options.devServer
+        'process.env.DEV_SERVER': (options && options.devServer) || false
       })
     ].concat(batfishConfig.webpackPlugins || []);
     const uglifyPlugin = new ParallelUglifyPlugin({
diff --git a/src/node/error-types.js b/src/node/error-types.js
index a252f54..66298cb 100644
--- a/src/node/error-types.js
+++ b/src/node/error-types.js
@@ -4,14 +4,9 @@
 const fasterror = require('fasterror');
 
 const errorTypes = Object.freeze({
-  ConfigValidationErrors: (fasterror('ConfigValidationErrors'): Class<
-    ConfigValidationErrors
-  >),
   ConfigFatalError: (fasterror('ConfigFatalError'): Class<WrappedError>),
-  // Singular ConfigValidationError is just one. Plural is an array, with a
-  // validationErrors property.
-  ConfigValidationError: (fasterror('ConfigValidationError'): Class<
-    WrappedError
+  ConfigValidationError: (fasterror('ConfigValidationErrors'): Class<
+    ConfigValidationError
   >),
   WebpackFatalError: (fasterror('WebpackFatalError'): Class<WrappedError>),
   WebpackCompilationError: (fasterror('WebpackCompilationError'): Class<
diff --git a/src/node/get-loggable-error-message.js b/src/node/get-loggable-error-message.js
index 8a99bc7..02983d3 100644
--- a/src/node/get-loggable-error-message.js
+++ b/src/node/get-loggable-error-message.js
@@ -10,26 +10,24 @@ const renderPrettyErrorStack = require('./render-pretty-error-stack');
 // Otherwise return undefined and expect the consumer to deal with the Error's
 // message.
 function getLoggableErrorMessage(error: Error): string | void {
-  let message = chalk.red.bold('Error: ');
+  let result = chalk.red.bold('Error: ');
 
   if (error instanceof errorTypes.ConfigFatalError) {
-    message += 'Fatal configuration error.\n\n';
-    message += `${error.message}\n`;
-    return message;
+    result += 'Fatal configuration error.\n\n';
+    result += `${error.message}\n`;
+    return result;
   }
 
   if (error instanceof errorTypes.ConfigValidationError) {
-    message += 'Invalid configuration.\n\n';
-    message += `${error.message}\n`;
-    return message;
-  }
-
-  if (error instanceof errorTypes.ConfigValidationErrors) {
-    message += 'Invalid configuration.\n\n';
-    error.validationErrors.forEach(validationError => {
-      message += `- ${validationError}\n`;
-    });
-    return message;
+    result += 'Invalid configuration.\n\n';
+    if (error.messages) {
+      error.messages.forEach(m => {
+        result += `- ${m}\n`;
+      });
+    } else {
+      result += `${error.message}\n`;
+    }
+    return result;
   }
 
   if (error instanceof errorTypes.WebpackFatalError) {
@@ -40,38 +38,38 @@ function getLoggableErrorMessage(error: Error): string | void {
     ]
       .map(x => chalk.yellow(x))
       .join(', ');
-    message += `Webpack fatal error. Please check any Batfish configuration options that pass directly into Webpack configuration (${webpackOptions}).\n\n`;
-    message += `Error message: ${error.originalError.message}\n`;
-    return message;
+    result += `Webpack fatal error. Please check any Batfish configuration options that pass directly into Webpack configuration (${webpackOptions}).\n\n`;
+    result += `Error message: ${error.originalError.message}\n`;
+    return result;
   }
 
   if (error instanceof errorTypes.WebpackCompilationError) {
-    message += `Webpack compilation error.\n\n`;
+    result += `Webpack compilation error.\n\n`;
     const formattedMessages = webpackFormatMessages(error.stats);
     formattedMessages.errors.forEach(errorMessage => {
-      message += `${errorMessage}\n`;
+      result += `${errorMessage}\n`;
     });
-    return message;
+    return result;
   }
 
   if (error instanceof errorTypes.CssCompilationError) {
-    message += `CSS compilation error. This may mean you have non-standard syntax that you forgot to compile with PostCSS.\n\n`;
-    message += `${error.message}\n`;
-    return message;
+    result += `CSS compilation error. This may mean you have non-standard syntax that you forgot to compile with PostCSS.\n\n`;
+    result += `${error.message}\n`;
+    return result;
   }
 
   if (error instanceof errorTypes.WebpackNodeParseError) {
-    message += `Failed to parse Webpack-compiled version of static-render-pages.js\n\n`;
-    message += `Error message: ${error.originalError.message}\n`;
-    return message;
+    result += `Failed to parse Webpack-compiled version of static-render-pages.js\n\n`;
+    result += `Error message: ${error.originalError.message}\n`;
+    return result;
   }
 
   if (error instanceof errorTypes.WebpackNodeExecutionError) {
-    message += `Failed to execute Webpack-compiled version of static-render-pages.js.\n${chalk.bold(
+    result += `Failed to execute Webpack-compiled version of static-render-pages.js.\n${chalk.bold(
       'You may be importing a JS file that cannot run in Node.'
     )}\n\n`;
-    message += renderPrettyErrorStack(error.originalError);
-    return message;
+    result += renderPrettyErrorStack(error.originalError);
+    return result;
   }
 
   return;
diff --git a/src/node/get-pages-data.js b/src/node/get-pages-data.js
index 9d489f6..65fed0f 100644
--- a/src/node/get-pages-data.js
+++ b/src/node/get-pages-data.js
@@ -52,27 +52,25 @@ function getPagesData(
 
     let pagesData = {};
     const registerPage = (filePath: string): Promise<void> => {
-      return pTry(() => {
-        if (isUnprocessed(filePath)) {
-          return;
-        }
-        return pify(fs.readFile)(filePath, 'utf8').then((content: string) => {
-          const isMarkdown = path.extname(filePath) === '.md';
-          const grayMatterOptions = isMarkdown
-            ? { delims: ['---', '---'] }
-            : { delims: ['/*---', '---*/'] };
-          const parsedFrontMatter = grayMatter(content, grayMatterOptions);
-          const published = parsedFrontMatter.data.published !== false;
-          if (!published && batfishConfig.production) return;
+      if (isUnprocessed(filePath)) {
+        return Promise.resolve();
+      }
+      return pify(fs.readFile)(filePath, 'utf8').then((content: string) => {
+        const isMarkdown = path.extname(filePath) === '.md';
+        const grayMatterOptions = isMarkdown
+          ? { delims: ['---', '---'] }
+          : { delims: ['/*---', '---*/'] };
+        const parsedFrontMatter = grayMatter(content, grayMatterOptions);
+        const published = parsedFrontMatter.data.published !== false;
+        if (!published && batfishConfig.production) return;
 
-          const pagePath = pageFilePathToUrlPath(filePath);
-          const pageData = {
-            filePath,
-            path: pagePath,
-            frontMatter: parsedFrontMatter.data
-          };
-          pagesData[pagePath] = pageData;
-        });
+        const pagePath = pageFilePathToUrlPath(filePath);
+        const pageData = {
+          filePath,
+          path: pagePath,
+          frontMatter: parsedFrontMatter.data
+        };
+        pagesData[pagePath] = pageData;
       });
     };
 
diff --git a/src/node/serve-static.js b/src/node/serve-static.js
index 2ebc4e5..200a3b5 100644
--- a/src/node/serve-static.js
+++ b/src/node/serve-static.js
@@ -24,10 +24,10 @@ function serveStatic(
   let batfishConfig;
   try {
     batfishConfig = validateConfig(rawConfig, projectDirectory);
-  } catch (ConfigValidationErrors) {
+  } catch (configError) {
     // setImmediate allows us to return the emitter before emitting the error.
     setImmediate(() => {
-      emitError(ConfigValidationErrors);
+      emitError(configError);
     });
     return emitter;
   }
diff --git a/src/node/start.js b/src/node/start.js
index 5953b4e..90d58d4 100644
--- a/src/node/start.js
+++ b/src/node/start.js
@@ -39,10 +39,10 @@ function start(rawConfig?: Object, projectDirectory?: string): EventEmitter {
   let batfishConfig;
   try {
     batfishConfig = validateConfig(rawConfig, projectDirectory);
-  } catch (configValidationErrors) {
+  } catch (configError) {
     // setImmediate allows us to return the emitter before emitting the error.
     setImmediate(() => {
-      emitError(configValidationErrors);
+      emitError(configError);
     });
     return emitter;
   }
diff --git a/src/node/validate-config.js b/src/node/validate-config.js
index 07ca8d1..fb8127c 100644
--- a/src/node/validate-config.js
+++ b/src/node/validate-config.js
@@ -15,41 +15,144 @@ const errorTypes = require('./error-types');
 
 // !!!
 // Whenever you add a new configuration property,
-// you'll need to add it to the following list.
-const validConfigProperties = new Set([
-  'siteBasePath',
-  'siteOrigin',
-  'applicationWrapperPath',
-  'stylesheets',
-  'browserslist',
-  'pagesDirectory',
-  'outputDirectory',
-  'temporaryDirectory',
-  'dataSelectors',
-  'vendorModules',
-  'hijackLinks',
-  'webpackLoaders',
-  'webpackPlugins',
-  'webpackStaticIgnore',
-  'babelPlugins',
-  'babelPresets',
-  'babelExclude',
-  'postcssPlugins',
-  'pageSpecificCss',
-  'fileLoaderExtensions',
-  'jsxtremeMarkdownOptions',
-  'includePromisePolyfill',
-  'inlineJs',
-  'production',
-  'developmentDevtool',
-  'productionDevtool',
-  'clearOutputDirectory',
-  'unprocessedPageFiles',
-  'webpackConfigClientTransform',
-  'webpackConfigStaticTransform',
-  'port',
-  'verbose'
-]);
+// you'll need to add it to this schema.
+const configSchema = {
+  siteBasePath: {
+    validator: _.isString,
+    description: 'string'
+  },
+  siteOrigin: {
+    validator: _.isString,
+    description: 'string'
+  },
+  applicationWrapperPath: {
+    validator: isAbsolutePath,
+    description: 'absolute path'
+  },
+  stylesheets: {
+    validator: isArrayOf(isValidStylesheetItem),
+    description:
+      'array of absolute file paths or globs, absolute URLs, or arrays of these things'
+  },
+  browserslist: {
+    validator: x => _.isString(x) || isArrayOf(_.isString)(x),
+    description: 'string or array of strings'
+  },
+  pagesDirectory: {
+    validator: isAbsolutePath,
+    description: 'absolute path'
+  },
+  outputDirectory: {
+    validator: isAbsolutePath,
+    description: 'absolute path'
+  },
+  temporaryDirectory: {
+    validator: isAbsolutePath,
+    description: 'absolute path'
+  },
+  dataSelectors: {
+    validator: x => _.isPlainObject(x) && _.every(x, _.isFunction),
+    description: 'object whose values are functions'
+  },
+  vendorModules: {
+    validator: isArrayOf(_.isString),
+    description: 'array of strings'
+  },
+  hijackLinks: {
+    validator: _.isBoolean,
+    description: 'boolean'
+  },
+  webpackLoaders: {
+    validator: isArrayOf(_.isPlainObject),
+    description: 'array of Webpack Rule objects'
+  },
+  webpackPlugins: {
+    validator: isArrayOf(_.isObject),
+    description: 'array of Webpack plugins'
+  },
+  // No good way to validate webpackStaticIgnore, which is a Webpack Condition
+  // so might be almost any type:
+  // https://webpack.js.org/configuration/module/#condition
+  webpackStaticIgnore: {
+    validator: () => true
+  },
+  babelPlugins: {
+    validator: isArrayOf(_.isFunction),
+    description:
+      'array of functions (require your plugins, do not reference them with a string)'
+  },
+  babelPresets: {
+    validator: isArrayOf(_.isFunction),
+    description:
+      'array of functions (require your presets, do not reference them with a string)'
+  },
+  // No good way to validate babelExclude, which is a Webpack Condition
+  // (see above).
+  babelExclude: {
+    validator: () => true
+  },
+  postcssPlugins: {
+    validator: x => _.isFunction(x) || isArrayOf(_.isFunction)(x),
+    description: 'function or array of functions'
+  },
+  fileLoaderExtensions: {
+    validator: x => isArrayOf(_.isString)(x) || _.isFunction(x),
+    description: 'array of strings or a function'
+  },
+  jsxtremeMarkdownOptions: {
+    validator: _.isPlainObject,
+    description: 'object'
+  },
+  pageSpecificCss: {
+    validator: _.isBoolean,
+    description: 'boolean'
+  },
+  includePromisePolyfill: {
+    validator: _.isBoolean,
+    description: 'boolean'
+  },
+  inlineJs: {
+    validator: isArrayOf(x => isAbsolutePath(x.filename) || _.isBoolean(x)),
+    description:
+      'array of objects with an absolute path for the "filename" prop, and an optional "boolean" uglify prop'
+  },
+  production: {
+    validator: _.isBoolean,
+    description: 'boolean'
+  },
+  developmentDevtool: {
+    validator: x => _.isString(x) || x === false,
+    description: 'string or the boolean value false'
+  },
+  productionDevtool: {
+    validator: x => _.isString(x) || x === false,
+    description: 'string or the boolean value false'
+  },
+  clearOutputDirectory: {
+    validator: _.isBoolean,
+    description: 'boolean'
+  },
+  unprocessedPageFiles: {
+    validator: isArrayOf(x => !isAbsolutePath(x)),
+    description: 'globs relative to the pagesDirectory, not absolute paths'
+  },
+  webpackConfigClientTransform: {
+    validator: _.isFunction,
+    description: 'function'
+  },
+  webpackConfigStaticTransform: {
+    validator: _.isFunction,
+    description: 'function'
+  },
+  port: {
+    validator: _.isNumber,
+    description: 'number'
+  },
+  verbose: {
+    validator: _.isBoolean,
+    description: 'boolean'
+  }
+};
 
 // Add defaults to a raw config object, and validate it.
 // All options and defaults are documented in the README.
@@ -124,14 +227,6 @@ function validateConfig(
     config.postcssPlugins = defaultPostcssPlugins;
   }
 
-  Object.keys(config).forEach(propertyName => {
-    if (!validConfigProperties.has(propertyName)) {
-      configErrors.push(
-        `${chalk.yellow(propertyName)} is not a valid configuration property.`
-      );
-    }
-  });
-
   const validatePropertyType = (
     propertyName: string,
     predicate: any => boolean,
@@ -148,30 +243,20 @@ function validateConfig(
     }
   };
 
-  // !!!
-  // Any changes to the acceptable values for a config property need to be
-  // accounted for in these validation functions.
-
-  validatePropertyType('siteBasePath', _.isString, 'string');
-
-  validatePropertyType('siteOrigin', _.isString, 'string');
-
-  validatePropertyType(
-    'applicationWrapperPath',
-    isAbsolutePath,
-    'absolute path'
-  );
-  validatePropertyType(
-    'applicationWrapperPath',
-    isExistingFile,
-    'file that exists'
-  );
-
-  validatePropertyType(
-    'stylesheets',
-    isArrayOf(isValidStylesheetItem),
-    'array of absolute file paths or globs, absolute URLs, or arrays of these things'
-  );
+  Object.keys(config).forEach(propertyName => {
+    const optionSchema = configSchema[propertyName];
+    if (!optionSchema) {
+      configErrors.push(
+        `${chalk.yellow(propertyName)} is not a valid configuration property.`
+      );
+      return;
+    }
+    validatePropertyType(
+      propertyName,
+      optionSchema.validator,
+      optionSchema.description
+    );
+  });
 
   // Validate every stylesheet entry. URLs will be checked when they actually
   // are called. And it's ok if globs don't point to existing files yet.
@@ -189,146 +274,10 @@ function validateConfig(
     });
   }
 
-  validatePropertyType(
-    'browserslist',
-    x => {
-      return _.isString(x) || isArrayOf(_.isString)(x);
-    },
-    'string or array of strings'
-  );
-
-  validatePropertyType('pagesDirectory', isAbsolutePath, 'absolute path');
-  validatePropertyType(
-    'pagesDirectory',
-    isExistingFile,
-    'directory that exists'
-  );
-
-  validatePropertyType('outputDirectory', isAbsolutePath, 'absolute path');
-
-  validatePropertyType('temporaryDirectory', isAbsolutePath, 'absolute path');
-
-  validatePropertyType(
-    'dataSelectors',
-    x => {
-      return _.isPlainObject(x) && _.every(x, _.isFunction);
-    },
-    'object whose property values are functions.'
-  );
-
-  validatePropertyType(
-    'vendorModules',
-    isArrayOf(_.isString),
-    'array of strings'
-  );
-
-  validatePropertyType('hijackLinks', _.isBoolean, 'boolean');
-
-  validatePropertyType(
-    'webpackLoaders',
-    isArrayOf(_.isPlainObject),
-    'array of Webpack Rule objects'
-  );
-
-  validatePropertyType(
-    'webpackPlugins',
-    isArrayOf(_.isObject),
-    'array of Webpack plugins'
-  );
-  // No good way to validate webpackStaticIgnore, which is a Webpack Condition
-  // so might be almost any type:
-  // https://webpack.js.org/configuration/module/#condition
-
-  validatePropertyType(
-    'babelPlugins',
-    isArrayOf(_.isFunction),
-    'array of functions (require your plugins, do not reference them with a string)'
-  );
-
-  validatePropertyType(
-    'babelPresets',
-    isArrayOf(_.isFunction),
-    'array of functions (require your presets, do not reference them with a string)'
-  );
-  // No good way to validate babelExclude, which is a Webpack Condition
-  // (see above).
-
-  validatePropertyType(
-    'postcssPlugins',
-    x => {
-      return _.isFunction(x) || isArrayOf(_.isFunction)(x);
-    },
-    'function or array of functions'
-  );
-
-  validatePropertyType('pageSpecificCss', _.isBoolean, 'boolean');
-
-  validatePropertyType(
-    'fileLoaderExtensions',
-    x => {
-      return isArrayOf(_.isString)(x) || _.isFunction(x);
-    },
-    'array of strings or function'
-  );
-
-  validatePropertyType('jsxtremeMarkdownOptions', _.isPlainObject, 'object');
-
-  validatePropertyType('includePromisePolyfill', _.isBoolean, 'boolean');
-
-  validatePropertyType(
-    'inlineJs',
-    isArrayOf(x => {
-      return isAbsolutePath(x.filename) || _.isBoolean(x);
-    }),
-    'array of objects with an absolute path for the "filename" prop, and an optional "boolean" uglify prop'
-  );
-
-  validatePropertyType('production', _.isBoolean, 'boolean');
-
-  validatePropertyType(
-    'developmentDevtool',
-    x => {
-      return _.isString(x) || x === false;
-    },
-    'string or the boolean value false'
-  );
-
-  validatePropertyType(
-    'productionDevtool',
-    x => {
-      return _.isString(x) || x === false;
-    },
-    'string or the boolean value false'
-  );
-
-  validatePropertyType('clearOutputDirectory', _.isBoolean, 'boolean');
-
-  validatePropertyType(
-    'unprocessedPageFiles',
-    isArrayOf(x => !isAbsolutePath(x)),
-    'globs relative to the pagesDirectory, not absolute paths'
-  );
-
-  validatePropertyType(
-    'webpackConfigClientTransform',
-    _.isFunction,
-    'function'
-  );
-
-  validatePropertyType(
-    'webpackConfigStaticTransform',
-    _.isFunction,
-    'function'
-  );
-
-  validatePropertyType('port', _.isNumber, 'number');
-
-  validatePropertyType('verbose', _.isBoolean, 'boolean');
-
   // Throw config errors.
   if (configErrors.length) {
-    const error = new errorTypes.ConfigValidationErrors();
-    error.validationErrors = configErrors;
+    const error = new errorTypes.ConfigValidationError();
+    error.messages = configErrors;
     throw error;
   }
 
@@ -361,7 +310,7 @@ function isAbsolutePath(value: *): boolean {
 }
 
 function isArrayOf(itemCheck: (*) => boolean): (*) => boolean {
-  return (value: *) => _.isArray(value) && value.every(itemCheck);
+  return (value: *) => Array.isArray(value) && value.every(itemCheck);
 }
 
 function isExistingFile(value: *): boolean {
diff --git a/src/node/write-context-module.js b/src/node/write-context-module.js
index bec321b..cde9220 100644
--- a/src/node/write-context-module.js
+++ b/src/node/write-context-module.js
@@ -8,7 +8,7 @@ const pify = require('pify');
 const slugg = require('slugg');
 const prettier = require('prettier');
 const getPagesData = require('./get-pages-data');
-const createModuleBatfishPage = require('./write-page-module');
+const writePageModule = require('./write-page-module');
 const writeDataModules = require('./write-data-modules');
 
 // Create a JS module that can be used during Webpack compilation,
@@ -51,7 +51,7 @@ function writeContextModule(
     const stringifiedRoutes = [];
     const stringifyPageRoute = (pagePath: string): Promise<void> => {
       const pageData = pagesData[pagePath];
-      return createModuleBatfishPage(
+      return writePageModule(
         batfishConfig,
         pageData
       ).then(pageModuleFilePath => {
@@ -86,8 +86,6 @@ function writeContextModule(
         // Webpack fills it with ansi colors so it's hard
         // to read in the devtools console. Strip those colors.
         const content = `
-          import stripColor from 'strip-color';
-
           export const batfishContext = {
             selectedConfig: {
               siteBasePath: '${String(batfishConfig.siteBasePath)}',
diff --git a/src/webpack/public/route-to.js b/src/webpack/public/route-to.js
index 0aef2da..510e4c7 100644
--- a/src/webpack/public/route-to.js
+++ b/src/webpack/public/route-to.js
@@ -1,15 +1,18 @@
 // @flow
 import { prefixUrl } from './prefix-url';
 
-let delayed: string | void;
-let onRouteTo: ?(string) => void;
+let delayed: ?string;
+let routeToHandler: ?(string) => void;
 
 function routeTo(url: string) {
-  if (!onRouteTo) {
+  if (delayed) {
+    return;
+  }
+  if (!routeToHandler) {
     delayed = url;
     return;
   }
-  onRouteTo(url);
+  routeToHandler(url);
 }
 
 function routeToPrefixed(url: string) {
@@ -19,9 +22,17 @@ function routeToPrefixed(url: string) {
 // Used by the Router to provide the function that actually does the routing.
 // This slight awkwardness is just to enable the user to
 // `require('@mapbox/batfish/modules/route-to')`.
-routeTo._onRouteTo = (handler: string => void) => {
-  onRouteTo = handler;
-  if (delayed) onRouteTo(delayed);
+routeTo._setRouteToHandler = (handler: string => void) => {
+  routeToHandler = handler;
+  if (delayed) {
+    routeToHandler(delayed);
+    delayed = null;
+  }
+};
+
+// For tests.
+routeTo._clearRouteToHandler = () => {
+  routeToHandler = null;
 };
 
 export { routeTo, routeToPrefixed };
diff --git a/src/webpack/router.js b/src/webpack/router.js
index 3ed4a28..3f008a8 100644
--- a/src/webpack/router.js
+++ b/src/webpack/router.js
@@ -76,13 +76,13 @@ class Router extends React.PureComponent<Props, State> {
       scrollToFragment();
     }
 
-    routeTo._onRouteTo(this.routeTo);
+    routeTo._setRouteToHandler(this.routeTo);
     window.addEventListener('popstate', event => {
       event.preventDefault();
       this.changePage({
-        pathname: document.location.pathname,
-        search: document.location.search,
-        hash: document.location.hash
+        pathname: window.location.pathname,
+        search: window.location.search,
+        hash: window.location.hash
       });
     });
 
@@ -124,7 +124,7 @@ class Router extends React.PureComponent<Props, State> {
   // - Adjust scroll position on the new page.
   changePage = (
     nextLocation: BatfishLocation,
-    options: Object = {},
+    options: { pushState?: boolean, scrollToTop?: boolean } = {},
     callback?: () => mixed
   ) => {
     const matchingRoute = findMatchingRoute(nextLocation.pathname);
@@ -133,8 +133,10 @@ class Router extends React.PureComponent<Props, State> {
       nextLocation.hash,
       nextLocation.search
     ].join('');
+    // Call the change-start callbacks immediately, not after the page chunk
+    // has already been fetched.
     const startChange = _invokeRouteChangeStartCallbacks(nextLocation.pathname);
-    matchingRoute
+    return matchingRoute
       .getPage()
       .then(pageModule => {
         return startChange.then(() => pageModule);


Test patch:
diff --git a/test/__snapshots__/compile-stylesheets.test.js.snap b/test/__snapshots__/compile-stylesheets.test.js.snap
new file mode 100644
index 0000000..5825680
--- /dev/null
+++ b/test/__snapshots__/compile-stylesheets.test.js.snap
@@ -0,0 +1,48 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`compileStylesheets minimizes in production 1`] = `
+"
+      @font-face {
+        font-family: 'mock-style';
+        src: url('https://www.mapbox.com/assets/mock-style.woff') format('woff2');
+      }
+      .mock-style { color: brown; }
+    
+@font-face{font-family:'a';src:url(/mock/base/path/assets/994d9585.woff2) format('woff2')}.a{color:pink}
+.b{color:orange}
+@font-face{font-family:'c';src:url(/mock/base/path/assets/675d172a.woff2) format('woff2')}.c{color:purple}
+@font-face{font-family:'e';src:url(/mock/base/path/assets/24153b0b.woff2) format('woff2')}.e{color:gray}/*# sourceMappingURL=batfish-styles-66ad6657a4c292a4e6dae6e2afc5bebb.css.map */"
+`;
+
+exports[`compileStylesheets writes expected CSS file 1`] = `
+"
+      @font-face {
+        font-family: 'mock-style';
+        src: url('https://www.mapbox.com/assets/mock-style.woff') format('woff2');
+      }
+      .mock-style { color: brown; }
+    
+@font-face {
+  font-family: 'a';
+  src: url('/mock/base/path/assets/994d9585.woff2') format('woff2');
+}
+
+.a { color: pink; }
+
+.b { color: orange; }
+
+@font-face {
+  font-family: 'c';
+  src: url('/mock/base/path/assets/675d172a.woff2') format('woff2');
+}
+
+.c { color: purple; }
+
+@font-face {
+  font-family: 'e';
+  src: url('/mock/base/path/assets/24153b0b.woff2') format('woff2');
+}
+
+.e { color: grey; }
+/*# sourceMappingURL=batfish-styles.css.map */"
+`;
diff --git a/test/__snapshots__/create-webpack-config-base.test.js.snap b/test/__snapshots__/create-webpack-config-base.test.js.snap
new file mode 100644
index 0000000..53f63da
--- /dev/null
+++ b/test/__snapshots__/create-webpack-config-base.test.js.snap
@@ -0,0 +1,559 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`createWebpackConfigBase default Webpack config 1`] = `
+Object {
+  "bail": false,
+  "cache": true,
+  "devtool": "source-map",
+  "module": Object {
+    "rules": Array [
+      Object {
+        "exclude": /node_modules\\\\/!\\(@mapbox\\\\/batfish\\\\/\\)/,
+        "test": /\\\\\\.jsx\\?\\$/,
+        "use": Array [
+          Object {
+            "loader": "babel-loader",
+            "options": Object {
+              "babelrc": false,
+              "cacheDirectory": "<PROJECT_ROOT>/babel-cache",
+              "compact": true,
+              "plugins": Array [
+                "syntax-dynamic-import",
+                "transform-class-properties",
+                Array [
+                  "@mapbox/babel-plugin-transform-jsxtreme-markdown",
+                  Object {
+                    "packageName": "@mapbox/batfish/modules/md",
+                    "rehypePlugins": undefined,
+                    "remarkPlugins": undefined,
+                  },
+                ],
+                "transform-react-jsx-source",
+                "transform-react-jsx-self",
+              ],
+              "presets": Array [
+                "es2015",
+                "react",
+              ],
+            },
+          },
+        ],
+      },
+      Object {
+        "test": /<PROJECT_ROOT>\\\\/test\\\\/fixtures\\\\/get-pages-data\\.\\*\\\\\\.md\\$/,
+        "use": Array [
+          Object {
+            "loader": "babel-loader",
+            "options": Object {
+              "babelrc": false,
+              "cacheDirectory": "<PROJECT_ROOT>/babel-cache",
+              "compact": true,
+              "plugins": Array [
+                "syntax-dynamic-import",
+                "transform-class-properties",
+                Array [
+                  "@mapbox/babel-plugin-transform-jsxtreme-markdown",
+                  Object {
+                    "packageName": "@mapbox/batfish/modules/md",
+                    "rehypePlugins": undefined,
+                    "remarkPlugins": undefined,
+                  },
+                ],
+                "transform-react-jsx-source",
+                "transform-react-jsx-self",
+              ],
+              "presets": Array [
+                "es2015",
+                "react",
+              ],
+            },
+          },
+          Object {
+            "loader": "@mapbox/jsxtreme-markdown-loader",
+            "options": Object {
+              "prependJs": Array [
+                "import { prefixUrl, prefixUrlAbsolute } from '@mapbox/batfish/modules/prefix-url';",
+                "import { routeTo, routeToPrefixed } from '@mapbox/batfish/modules/route-to';",
+              ],
+            },
+          },
+        ],
+      },
+      Object {
+        "loader": "file-loader",
+        "options": Object {
+          "digest": "hex",
+          "hash": "sha512",
+          "name": "[name].[ext]",
+        },
+        "test": /\\\\\\.\\(jpeg\\|jpg\\|png\\|gif\\|webp\\|mp4\\|webm\\|woff\\|woff2\\)\\$/,
+      },
+      Object {
+        "test": /\\\\\\.json\\$/,
+        "use": "json-loader",
+      },
+      Object {
+        "test": /<PROJECT_ROOT>\\\\/test\\\\/fixtures\\\\/get-pages-data\\.\\*\\\\\\.css\\$/,
+        "use": Array [
+          Object {
+            "loader": "babel-loader",
+            "options": Object {
+              "babelrc": false,
+              "cacheDirectory": "<PROJECT_ROOT>/babel-cache",
+              "compact": true,
+              "plugins": Array [
+                "syntax-dynamic-import",
+                "transform-class-properties",
+                Array [
+                  "@mapbox/babel-plugin-transform-jsxtreme-markdown",
+                  Object {
+                    "packageName": "@mapbox/batfish/modules/md",
+                    "rehypePlugins": undefined,
+                    "remarkPlugins": undefined,
+                  },
+                ],
+                "transform-react-jsx-source",
+                "transform-react-jsx-self",
+              ],
+              "presets": Array [
+                "es2015",
+                "react",
+              ],
+            },
+          },
+          Object {
+            "loader": "react-helmet-postcss-loader",
+            "options": Object {
+              "postcssPlugins": Array [
+                [Function],
+                [Function],
+                [Function],
+              ],
+            },
+          },
+        ],
+      },
+    ],
+  },
+  "output": Object {
+    "filename": "[name].js",
+    "path": "<PROJECT_ROOT>/_batfish_site",
+    "pathinfo": false,
+    "publicPath": "/assets/",
+  },
+  "performance": Object {
+    "hints": false,
+  },
+  "plugins": Array [
+    DefinePlugin {
+      "definitions": Object {
+        "process.env.NODE_ENV": "\\"development\\"",
+      },
+    },
+    WebpackChunkHash {
+      "additionalHashContent": [Function],
+      "algorithm": "md5",
+      "digest": "hex",
+    },
+  ],
+  "resolve": Object {
+    "alias": Object {
+      "@mapbox/batfish/data": "<PROJECT_ROOT>/_batfish_tmp/data",
+      "batfish-internal/application-wrapper": "<PROJECT_ROOT>/src/webpack/empty-application-wrapper.js",
+      "batfish-internal/context": "fake/batfish-context.js",
+    },
+  },
+  "resolveLoader": Object {
+    "alias": Object {
+      "react-helmet-postcss-loader": "<PROJECT_ROOT>/src/node/react-helmet-postcss-loader.js",
+    },
+    "modules": Array [
+      "<PROJECT_ROOT>/node_modules",
+      "node_modules",
+    ],
+  },
+}
+`;
+
+exports[`createWebpackConfigBase default production Webpack config 1`] = `
+Object {
+  "bail": true,
+  "cache": false,
+  "devtool": false,
+  "module": Object {
+    "rules": Array [
+      Object {
+        "exclude": /node_modules\\\\/!\\(@mapbox\\\\/batfish\\\\/\\)/,
+        "test": /\\\\\\.jsx\\?\\$/,
+        "use": Array [
+          Object {
+            "loader": "babel-loader",
+            "options": Object {
+              "babelrc": false,
+              "cacheDirectory": false,
+              "compact": true,
+              "plugins": Array [
+                "syntax-dynamic-import",
+                "transform-class-properties",
+                Array [
+                  "@mapbox/babel-plugin-transform-jsxtreme-markdown",
+                  Object {
+                    "packageName": "@mapbox/batfish/modules/md",
+                    "rehypePlugins": undefined,
+                    "remarkPlugins": undefined,
+                  },
+                ],
+                "transform-react-remove-prop-types",
+              ],
+              "presets": Array [
+                "es2015",
+                "react",
+              ],
+            },
+          },
+        ],
+      },
+      Object {
+        "test": /<PROJECT_ROOT>\\\\/test\\\\/fixtures\\\\/get-pages-data\\.\\*\\\\\\.md\\$/,
+        "use": Array [
+          Object {
+            "loader": "babel-loader",
+            "options": Object {
+              "babelrc": false,
+              "cacheDirectory": false,
+              "compact": true,
+              "plugins": Array [
+                "syntax-dynamic-import",
+                "transform-class-properties",
+                Array [
+                  "@mapbox/babel-plugin-transform-jsxtreme-markdown",
+                  Object {
+                    "packageName": "@mapbox/batfish/modules/md",
+                    "rehypePlugins": undefined,
+                    "remarkPlugins": undefined,
+                  },
+                ],
+                "transform-react-remove-prop-types",
+              ],
+              "presets": Array [
+                "es2015",
+                "react",
+              ],
+            },
+          },
+          Object {
+            "loader": "@mapbox/jsxtreme-markdown-loader",
+            "options": Object {
+              "prependJs": Array [
+                "import { prefixUrl, prefixUrlAbsolute } from '@mapbox/batfish/modules/prefix-url';",
+                "import { routeTo, routeToPrefixed } from '@mapbox/batfish/modules/route-to';",
+              ],
+            },
+          },
+        ],
+      },
+      Object {
+        "loader": "file-loader",
+        "options": Object {
+          "digest": "hex",
+          "hash": "sha512",
+          "name": "[name]-[hash].[ext]",
+        },
+        "test": /\\\\\\.\\(jpeg\\|jpg\\|png\\|gif\\|webp\\|mp4\\|webm\\|woff\\|woff2\\)\\$/,
+      },
+      Object {
+        "test": /\\\\\\.json\\$/,
+        "use": "json-loader",
+      },
+      Object {
+        "test": /<PROJECT_ROOT>\\\\/test\\\\/fixtures\\\\/get-pages-data\\.\\*\\\\\\.css\\$/,
+        "use": Array [
+          Object {
+            "loader": "babel-loader",
+            "options": Object {
+              "babelrc": false,
+              "cacheDirectory": false,
+              "compact": true,
+              "plugins": Array [
+                "syntax-dynamic-import",
+                "transform-class-properties",
+                Array [
+                  "@mapbox/babel-plugin-transform-jsxtreme-markdown",
+                  Object {
+                    "packageName": "@mapbox/batfish/modules/md",
+                    "rehypePlugins": undefined,
+                    "remarkPlugins": undefined,
+                  },
+                ],
+                "transform-react-remove-prop-types",
+              ],
+              "presets": Array [
+                "es2015",
+                "react",
+              ],
+            },
+          },
+          Object {
+            "loader": "react-helmet-postcss-loader",
+            "options": Object {
+              "postcssPlugins": Array [
+                [Function],
+                [Function],
+                [Function],
+                [Function],
+              ],
+            },
+          },
+        ],
+      },
+    ],
+  },
+  "output": Object {
+    "filename": "[name].js",
+    "path": "<PROJECT_ROOT>/_batfish_site",
+    "pathinfo": false,
+    "publicPath": "/assets/",
+  },
+  "performance": Object {
+    "hints": false,
+  },
+  "plugins": Array [
+    DefinePlugin {
+      "definitions": Object {
+        "process.env.NODE_ENV": "\\"production\\"",
+      },
+    },
+    WebpackChunkHash {
+      "additionalHashContent": [Function],
+      "algorithm": "md5",
+      "digest": "hex",
+    },
+  ],
+  "resolve": Object {
+    "alias": Object {
+      "@mapbox/batfish/data": "<PROJECT_ROOT>/_batfish_tmp/data",
+      "batfish-internal/application-wrapper": "<PROJECT_ROOT>/src/webpack/empty-application-wrapper.js",
+      "batfish-internal/context": "fake/batfish-context.js",
+    },
+  },
+  "resolveLoader": Object {
+    "alias": Object {
+      "react-helmet-postcss-loader": "<PROJECT_ROOT>/src/node/react-helmet-postcss-loader.js",
+    },
+    "modules": Array [
+      "<PROJECT_ROOT>/node_modules",
+      "node_modules",
+    ],
+  },
+}
+`;
+
+exports[`createWebpackConfigBase with all Batfish config options that make a difference 1`] = `
+Object {
+  "bail": false,
+  "cache": true,
+  "devtool": "source-map",
+  "module": Object {
+    "rules": Array [
+      Object {
+        "exclude": /node_modules\\\\/nothing/,
+        "test": /\\\\\\.jsx\\?\\$/,
+        "use": Array [
+          Object {
+            "loader": "babel-loader",
+            "options": Object {
+              "babelrc": false,
+              "cacheDirectory": "<PROJECT_ROOT>/babel-cache",
+              "compact": true,
+              "plugins": Array [
+                "syntax-dynamic-import",
+                "transform-class-properties",
+                Array [
+                  "@mapbox/babel-plugin-transform-jsxtreme-markdown",
+                  Object {
+                    "packageName": "@mapbox/batfish/modules/md",
+                    "rehypePlugins": undefined,
+                    "remarkPlugins": Array [
+                      [Function],
+                      [Function],
+                    ],
+                  },
+                ],
+                [Function],
+                [Function],
+                "transform-react-jsx-source",
+                "transform-react-jsx-self",
+              ],
+              "presets": Array [
+                "es2015",
+                "react",
+                [Function],
+                [Function],
+              ],
+            },
+          },
+        ],
+      },
+      Object {
+        "test": /<PROJECT_ROOT>\\\\/test\\\\/fixtures\\\\/get-pages-data\\.\\*\\\\\\.md\\$/,
+        "use": Array [
+          Object {
+            "loader": "babel-loader",
+            "options": Object {
+              "babelrc": false,
+              "cacheDirectory": "<PROJECT_ROOT>/babel-cache",
+              "compact": true,
+              "plugins": Array [
+                "syntax-dynamic-import",
+                "transform-class-properties",
+                Array [
+                  "@mapbox/babel-plugin-transform-jsxtreme-markdown",
+                  Object {
+                    "packageName": "@mapbox/batfish/modules/md",
+                    "rehypePlugins": undefined,
+                    "remarkPlugins": Array [
+                      [Function],
+                      [Function],
+                    ],
+                  },
+                ],
+                [Function],
+                [Function],
+                "transform-react-jsx-source",
+                "transform-react-jsx-self",
+              ],
+              "presets": Array [
+                "es2015",
+                "react",
+                [Function],
+                [Function],
+              ],
+            },
+          },
+          Object {
+            "loader": "@mapbox/jsxtreme-markdown-loader",
+            "options": Object {
+              "prependJs": Array [
+                "const add = (x, y) => x + y;",
+                "import egg from 'chicken';",
+                "import { prefixUrl, prefixUrlAbsolute } from '@mapbox/batfish/modules/prefix-url';",
+                "import { routeTo, routeToPrefixed } from '@mapbox/batfish/modules/route-to';",
+              ],
+              "remarkPlugins": Array [
+                [Function],
+                [Function],
+              ],
+            },
+          },
+        ],
+      },
+      Object {
+        "loader": "file-loader",
+        "options": Object {
+          "digest": "hex",
+          "hash": "sha512",
+          "name": "[name].[ext]",
+        },
+        "test": /\\\\\\.\\(txt\\|config\\)\\$/,
+      },
+      Object {
+        "test": /\\\\\\.json\\$/,
+        "use": "json-loader",
+      },
+      Object {
+        "test": /<PROJECT_ROOT>\\\\/test\\\\/fixtures\\\\/get-pages-data\\.\\*\\\\\\.css\\$/,
+        "use": Array [
+          Object {
+            "loader": "babel-loader",
+            "options": Object {
+              "babelrc": false,
+              "cacheDirectory": "<PROJECT_ROOT>/babel-cache",
+              "compact": true,
+              "plugins": Array [
+                "syntax-dynamic-import",
+                "transform-class-properties",
+                Array [
+                  "@mapbox/babel-plugin-transform-jsxtreme-markdown",
+                  Object {
+                    "packageName": "@mapbox/batfish/modules/md",
+                    "rehypePlugins": undefined,
+                    "remarkPlugins": Array [
+                      [Function],
+                      [Function],
+                    ],
+                  },
+                ],
+                [Function],
+                [Function],
+                "transform-react-jsx-source",
+                "transform-react-jsx-self",
+              ],
+              "presets": Array [
+                "es2015",
+                "react",
+                [Function],
+                [Function],
+              ],
+            },
+          },
+          Object {
+            "loader": "react-helmet-postcss-loader",
+            "options": Object {
+              "postcssPlugins": Array [
+                [Function],
+                [Function],
+                [Function],
+              ],
+            },
+          },
+        ],
+      },
+      Object {
+        "test": /\\\\\\.jpg\\$/,
+        "use": "jpg-loader",
+      },
+      Object {
+        "test": /\\\\\\.png\\$/,
+        "use": "png-loader",
+      },
+    ],
+  },
+  "output": Object {
+    "filename": "[name].js",
+    "path": "<PROJECT_ROOT>/_batfish_site",
+    "pathinfo": true,
+    "publicPath": "/site/base/path/assets/",
+  },
+  "performance": Object {
+    "hints": "warning",
+  },
+  "plugins": Array [
+    DefinePlugin {
+      "definitions": Object {
+        "process.env.NODE_ENV": "\\"development\\"",
+      },
+    },
+    WebpackChunkHash {
+      "additionalHashContent": [Function],
+      "algorithm": "md5",
+      "digest": "hex",
+    },
+  ],
+  "resolve": Object {
+    "alias": Object {
+      "@mapbox/batfish/data": "<PROJECT_ROOT>/test/fake/temporary/directory/data",
+      "batfish-internal/application-wrapper": "<PROJECT_ROOT>/test/fixtures/empty-component.js",
+      "batfish-internal/context": "fake/batfish-context.js",
+    },
+  },
+  "resolveLoader": Object {
+    "alias": Object {
+      "react-helmet-postcss-loader": "<PROJECT_ROOT>/src/node/react-helmet-postcss-loader.js",
+    },
+    "modules": Array [
+      "<PROJECT_ROOT>/node_modules",
+      "node_modules",
+    ],
+  },
+}
+`;
diff --git a/test/__snapshots__/create-webpack-config-client.test.js.snap b/test/__snapshots__/create-webpack-config-client.test.js.snap
new file mode 100644
index 0000000..78f4f0d
--- /dev/null
+++ b/test/__snapshots__/create-webpack-config-client.test.js.snap
@@ -0,0 +1,269 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`createWebpackConfigClient default Webpack config 1`] = `
+Object {
+  "createdWebpackConfigBase": true,
+  "entry": Object {
+    "app": Array [
+      "<PROJECT_ROOT>/src/webpack/batfish-app.js",
+    ],
+    "vendor": Array [
+      "<PROJECT_ROOT>/node_modules/es6-promise/auto.js",
+      "<PROJECT_ROOT>/node_modules/react",
+      "<PROJECT_ROOT>/node_modules/react-dom",
+      "<PROJECT_ROOT>/node_modules/react-helmet",
+      "<PROJECT_ROOT>/node_modules/@mapbox/scroll-restorer/index.js",
+      "<PROJECT_ROOT>/node_modules/@mapbox/link-hijacker/index.js",
+      "<PROJECT_ROOT>/node_modules/strip-color/index.js",
+    ],
+  },
+  "output": Object {
+    "chunkFilename": "[name].chunk.js",
+    "filename": "[name].js",
+  },
+  "plugins": Array [
+    AssetsWebpackPlugin {
+      "options": Object {
+        "filename": "assets.json",
+        "fullPath": true,
+        "path": "<PROJECT_ROOT>/_batfish_site",
+        "prettyPrint": false,
+        "processOutput": [Function],
+        "update": false,
+      },
+      "writer": [Function],
+    },
+    CommonsChunkPlugin {
+      "async": undefined,
+      "children": undefined,
+      "chunkNames": Array [
+        "vendor",
+      ],
+      "filenameTemplate": undefined,
+      "ident": "<PROJECT_ROOT>/node_modules/webpack/lib/optimize/CommonsChunkPlugin.js3",
+      "minChunks": undefined,
+      "minSize": undefined,
+      "selectedChunks": undefined,
+    },
+    CommonsChunkPlugin {
+      "async": undefined,
+      "children": true,
+      "chunkNames": Array [
+        "app",
+      ],
+      "filenameTemplate": undefined,
+      "ident": "<PROJECT_ROOT>/node_modules/webpack/lib/optimize/CommonsChunkPlugin.js4",
+      "minChunks": 4,
+      "minSize": undefined,
+      "selectedChunks": undefined,
+    },
+    CommonsChunkPlugin {
+      "async": undefined,
+      "children": undefined,
+      "chunkNames": Array [
+        "manifest",
+      ],
+      "filenameTemplate": undefined,
+      "ident": "<PROJECT_ROOT>/node_modules/webpack/lib/optimize/CommonsChunkPlugin.js5",
+      "minChunks": undefined,
+      "minSize": undefined,
+      "selectedChunks": undefined,
+    },
+    DefinePlugin {
+      "definitions": Object {
+        "process.env.DEV_SERVER": false,
+      },
+    },
+  ],
+  "resolve": Object {
+    "alias": Object {
+      "react": "<PROJECT_ROOT>/node_modules/react",
+      "react-dom": "<PROJECT_ROOT>/node_modules/react-dom",
+      "react-helmet": "<PROJECT_ROOT>/node_modules/react-helmet",
+    },
+  },
+  "target": "web",
+}
+`;
+
+exports[`createWebpackConfigClient default production Webpack config 1`] = `
+Object {
+  "createdWebpackConfigBase": true,
+  "entry": Object {
+    "app": Array [
+      "<PROJECT_ROOT>/src/webpack/batfish-app.js",
+    ],
+    "vendor": Array [
+      "<PROJECT_ROOT>/node_modules/es6-promise/auto.js",
+      "<PROJECT_ROOT>/node_modules/react",
+      "<PROJECT_ROOT>/node_modules/react-dom",
+      "<PROJECT_ROOT>/node_modules/react-helmet",
+      "<PROJECT_ROOT>/node_modules/@mapbox/scroll-restorer/index.js",
+      "<PROJECT_ROOT>/node_modules/@mapbox/link-hijacker/index.js",
+      "<PROJECT_ROOT>/node_modules/strip-color/index.js",
+    ],
+  },
+  "output": Object {
+    "chunkFilename": "[name]-[chunkhash].chunk.js",
+    "filename": "[name]-[chunkhash].js",
+  },
+  "plugins": Array [
+    AssetsWebpackPlugin {
+      "options": Object {
+        "filename": "assets.json",
+        "fullPath": true,
+        "path": "<PROJECT_ROOT>/_batfish_site",
+        "prettyPrint": false,
+        "processOutput": [Function],
+        "update": false,
+      },
+      "writer": [Function],
+    },
+    CommonsChunkPlugin {
+      "async": undefined,
+      "children": undefined,
+      "chunkNames": Array [
+        "vendor",
+      ],
+      "filenameTemplate": undefined,
+      "ident": "<PROJECT_ROOT>/node_modules/webpack/lib/optimize/CommonsChunkPlugin.js6",
+      "minChunks": undefined,
+      "minSize": undefined,
+      "selectedChunks": undefined,
+    },
+    CommonsChunkPlugin {
+      "async": undefined,
+      "children": true,
+      "chunkNames": Array [
+        "app",
+      ],
+      "filenameTemplate": undefined,
+      "ident": "<PROJECT_ROOT>/node_modules/webpack/lib/optimize/CommonsChunkPlugin.js7",
+      "minChunks": 4,
+      "minSize": undefined,
+      "selectedChunks": undefined,
+    },
+    CommonsChunkPlugin {
+      "async": undefined,
+      "children": undefined,
+      "chunkNames": Array [
+        "manifest",
+      ],
+      "filenameTemplate": undefined,
+      "ident": "<PROJECT_ROOT>/node_modules/webpack/lib/optimize/CommonsChunkPlugin.js8",
+      "minChunks": undefined,
+      "minSize": undefined,
+      "selectedChunks": undefined,
+    },
+    DefinePlugin {
+      "definitions": Object {
+        "process.env.DEV_SERVER": false,
+      },
+    },
+    FasterUglifyPlugin {
+      "options": Object {
+        "sourceMap": false,
+        "uglifyJS": Object {},
+      },
+    },
+  ],
+  "resolve": Object {
+    "alias": Object {
+      "react": "<PROJECT_ROOT>/node_modules/react",
+      "react-dom": "<PROJECT_ROOT>/node_modules/react-dom",
+      "react-helmet": "<PROJECT_ROOT>/node_modules/react-helmet",
+    },
+  },
+  "target": "web",
+}
+`;
+
+exports[`createWebpackConfigClient with all Batfish config options that make a difference 1`] = `
+Object {
+  "createdWebpackConfigBase": true,
+  "entry": Object {
+    "app": Array [
+      "<PROJECT_ROOT>/test/sea-creatures.js",
+      "<PROJECT_ROOT>/test/land-creatures.js",
+      "<PROJECT_ROOT>/src/webpack/batfish-app.js",
+    ],
+    "vendor": Array [
+      "<PROJECT_ROOT>/node_modules/react",
+      "<PROJECT_ROOT>/node_modules/react-dom",
+      "<PROJECT_ROOT>/node_modules/react-helmet",
+      "<PROJECT_ROOT>/node_modules/@mapbox/scroll-restorer/index.js",
+      "<PROJECT_ROOT>/node_modules/@mapbox/link-hijacker/index.js",
+      "<PROJECT_ROOT>/node_modules/strip-color/index.js",
+      "pigman",
+      "revenge-of-pigman",
+    ],
+  },
+  "output": Object {
+    "chunkFilename": "[name].chunk.js",
+    "filename": "[name].js",
+  },
+  "plugins": Array [
+    AssetsWebpackPlugin {
+      "options": Object {
+        "filename": "assets.json",
+        "fullPath": true,
+        "path": "<PROJECT_ROOT>/test/fake/output/directory",
+        "prettyPrint": false,
+        "processOutput": [Function],
+        "update": false,
+      },
+      "writer": [Function],
+    },
+    CommonsChunkPlugin {
+      "async": undefined,
+      "children": undefined,
+      "chunkNames": Array [
+        "vendor",
+      ],
+      "filenameTemplate": undefined,
+      "ident": "<PROJECT_ROOT>/node_modules/webpack/lib/optimize/CommonsChunkPlugin.js9",
+      "minChunks": undefined,
+      "minSize": undefined,
+      "selectedChunks": undefined,
+    },
+    CommonsChunkPlugin {
+      "async": undefined,
+      "children": true,
+      "chunkNames": Array [
+        "app",
+      ],
+      "filenameTemplate": undefined,
+      "ident": "<PROJECT_ROOT>/node_modules/webpack/lib/optimize/CommonsChunkPlugin.js10",
+      "minChunks": 4,
+      "minSize": undefined,
+      "selectedChunks": undefined,
+    },
+    CommonsChunkPlugin {
+      "async": undefined,
+      "children": undefined,
+      "chunkNames": Array [
+        "manifest",
+      ],
+      "filenameTemplate": undefined,
+      "ident": "<PROJECT_ROOT>/node_modules/webpack/lib/optimize/CommonsChunkPlugin.js11",
+      "minChunks": undefined,
+      "minSize": undefined,
+      "selectedChunks": undefined,
+    },
+    DefinePlugin {
+      "definitions": Object {
+        "process.env.DEV_SERVER": false,
+      },
+    },
+  ],
+  "resolve": Object {
+    "alias": Object {
+      "react": "<PROJECT_ROOT>/node_modules/react",
+      "react-dom": "<PROJECT_ROOT>/node_modules/react-dom",
+      "react-helmet": "<PROJECT_ROOT>/node_modules/react-helmet",
+    },
+  },
+  "target": "web",
+  "underwentClientTransform": true,
+}
+`;
diff --git a/test/__snapshots__/create-webpack-config-static.test.js.snap b/test/__snapshots__/create-webpack-config-static.test.js.snap
new file mode 100644
index 0000000..a23f94b
--- /dev/null
+++ b/test/__snapshots__/create-webpack-config-static.test.js.snap
@@ -0,0 +1,84 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`createWebpackConfigStatic default Webpack config 1`] = `
+Object {
+  "createdWebpackConfigBase": true,
+  "devtool": "source-map",
+  "entry": Object {
+    "static": "<PROJECT_ROOT>/src/webpack/static-render-pages.js",
+  },
+  "externals": Object {
+    "mkdirp": "mkdirp",
+    "react": "react",
+    "react-dom": "react-dom",
+    "uglify-js": "uglify-js",
+  },
+  "node": Object {
+    "Buffer": false,
+    "__dirname": false,
+    "__filename": false,
+    "console": false,
+    "global": false,
+    "process": false,
+    "setImmediate": false,
+  },
+  "output": Object {
+    "filename": "./static-render-pages.js",
+    "libraryTarget": "commonjs2",
+  },
+  "plugins": Array [
+    LimitChunkCountPlugin {
+      "options": Object {
+        "maxChunks": 1,
+      },
+    },
+  ],
+  "target": "node",
+}
+`;
+
+exports[`createWebpackConfigStatic with all Batfish config options that make a difference 1`] = `
+Object {
+  "createdWebpackConfigBase": true,
+  "devtool": "source-map",
+  "entry": Object {
+    "static": "<PROJECT_ROOT>/src/webpack/static-render-pages.js",
+  },
+  "externals": Object {
+    "mkdirp": "mkdirp",
+    "react": "react",
+    "react-dom": "react-dom",
+    "uglify-js": "uglify-js",
+  },
+  "module": Object {
+    "rules": Array [
+      Object {
+        "loader": "ignore-loader",
+        "test": /stuff\\\\/to\\\\/ignore/,
+      },
+    ],
+  },
+  "node": Object {
+    "Buffer": false,
+    "__dirname": false,
+    "__filename": false,
+    "console": false,
+    "global": false,
+    "process": false,
+    "setImmediate": false,
+  },
+  "output": Object {
+    "filename": "./static-render-pages.js",
+    "libraryTarget": "commonjs2",
+  },
+  "plugins": Array [
+    LimitChunkCountPlugin {
+      "options": Object {
+        "maxChunks": 1,
+      },
+    },
+  ],
+  "target": "node",
+  "underwentStaticTransform": true,
+}
+`;
diff --git a/test/__snapshots__/postcss-absolute-urls.test.js.snap b/test/__snapshots__/postcss-absolute-urls.test.js.snap
new file mode 100644
index 0000000..4328912
--- /dev/null
+++ b/test/__snapshots__/postcss-absolute-urls.test.js.snap
@@ -0,0 +1,14 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`postcssAbsoluteUrls works 1`] = `
+"
+      @font-face {
+        font-family: \\"Open Sans\\";
+        src: url(\\"https://www.fake-place.com/path/fonts/OpenSans-Regular-webfont.woff2\\") format(\\"woff2\\"),
+              url(\\"https://www.fake-place.com/fonts/OpenSans-Regular-webfont.woff\\") format(\\"woff\\");
+      }
+      body {
+        background: url('data:blergh');
+      }
+    "
+`;
diff --git a/test/__snapshots__/router.test.js.snap b/test/__snapshots__/router.test.js.snap
new file mode 100644
index 0000000..950e55b
--- /dev/null
+++ b/test/__snapshots__/router.test.js.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Router instance.changePage invokes change-start and change-end callbacks 1`] = `
+<StartingComponent
+  location={
+    Object {
+      "hash": "",
+      "pathname": "/magic/",
+      "search": "",
+    }
+  }
+/>
+`;
+
+exports[`Router renders the initial page 1`] = `
+<StartingComponent
+  location={
+    Object {
+      "hash": "",
+      "pathname": "/magic/",
+      "search": "",
+    }
+  }
+/>
+`;
diff --git a/test/__snapshots__/validate-config.test.js.snap b/test/__snapshots__/validate-config.test.js.snap
index ae6fe15..517dea3 100644
--- a/test/__snapshots__/validate-config.test.js.snap
+++ b/test/__snapshots__/validate-config.test.js.snap
@@ -2,7 +2,7 @@
 
 exports[`validateConfig defaults 1`] = `
 Object {
-  "applicationWrapperPath": "../webpack/empty-application-wrapper.js",
+  "applicationWrapperPath": "<PROJECT_ROOT>/src/webpack/empty-application-wrapper.js",
   "babelExclude": /node_modules\\\\/!\\(@mapbox\\\\/batfish\\\\/\\)/,
   "browserslist": Array [
     "> 5%",
@@ -39,18 +39,3 @@ Object {
   "verbose": false,
 }
 `;
-
-exports[`validateConfig fileLoaderExtensions transform function 1`] = `
-Array [
-  "jpeg",
-  "jpg",
-  "png",
-  "gif",
-  "webp",
-  "mp4",
-  "webm",
-  "woff",
-  "woff2",
-  "svg",
-]
-`;
diff --git a/test/__snapshots__/write-context-module.test.js.snap b/test/__snapshots__/write-context-module.test.js.snap
new file mode 100644
index 0000000..4772211
--- /dev/null
+++ b/test/__snapshots__/write-context-module.test.js.snap
@@ -0,0 +1,80 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`writeContextModule with 404 and some config variation 1`] = `
+"export const batfishContext = {
+  selectedConfig: {
+    siteBasePath: \\"/different/base/path\\",
+    siteOrigin: \\"\\",
+    hijackLinks: false
+  },
+  routes: [
+    {
+      path: \\"/one/\\",
+      getPage: () =>
+        import(/* webpackChunkName: \\"one\\" */
+        \\"module/file/path/forfake/pages/directory/one.js\\")
+    },
+    {
+      path: \\"/three/four/\\",
+      getPage: () =>
+        import(/* webpackChunkName: \\"three-four\\" */
+        \\"module/file/path/forfake/pages/directory/three/four/index.js\\")
+    },
+    {
+      path: \\"/404/\\",
+      getPage: () =>
+        import(/* webpackChunkName: \\"not-found\\" */
+        \\"module/file/path/forfake/pages/directory/404.js\\"),
+
+      is404: true
+    }
+  ],
+  notFoundRoute: {
+    path: \\"/404/\\",
+    getPage: () =>
+      import(/* webpackChunkName: \\"not-found\\" */
+      \\"module/file/path/forfake/pages/directory/404.js\\"),
+
+    is404: true
+  }
+};
+"
+`;
+
+exports[`writeContextModule without 404 1`] = `
+"export const batfishContext = {
+  selectedConfig: {
+    siteBasePath: \\"/fake/site/base/path\\",
+    siteOrigin: \\"https://www.fake-origin.com\\",
+    hijackLinks: true
+  },
+  routes: [
+    {
+      path: \\"/one/\\",
+      getPage: () =>
+        import(/* webpackChunkName: \\"one\\" */
+        \\"module/file/path/forfake/pages/directory/one.js\\")
+    },
+    {
+      path: \\"/two/\\",
+      getPage: () =>
+        import(/* webpackChunkName: \\"two\\" */
+        \\"module/file/path/forfake/pages/directory/two/index.md\\")
+    },
+    {
+      path: \\"/three/four/\\",
+      getPage: () =>
+        import(/* webpackChunkName: \\"three-four\\" */
+        \\"module/file/path/forfake/pages/directory/three/four/index.js\\")
+    }
+  ],
+  notFoundRoute: {
+    path: \\"\\",
+    getPage: () => {
+      throw new Error(\\"No matching route.\\");
+    },
+    is404: true
+  }
+};
+"
+`;
diff --git a/test/compile-stylesheets.test.js b/test/compile-stylesheets.test.js
new file mode 100644
index 0000000..a003b76
--- /dev/null
+++ b/test/compile-stylesheets.test.js
@@ -0,0 +1,101 @@
+'use strict';
+
+const got = require('got');
+const fs = require('fs');
+const pify = require('pify');
+const path = require('path');
+const del = require('del');
+const tempy = require('tempy');
+const compileStylesheets = require('../src/node/compile-stylesheets');
+
+jest.mock('got', () => {
+  return jest.fn();
+});
+
+describe('compileStylesheets', () => {
+  let tmp;
+  let batfishConfig;
+
+  beforeEach(() => {
+    tmp = tempy.directory();
+    // This url() reference should be transformed into an absolute URL.
+    const mockUrlCss = `
+      @font-face {
+        font-family: 'mock-style';
+        src: url('assets/mock-style.woff') format('woff2');
+      }
+      .mock-style { color: brown; }
+    `;
+    got.mockImplementation(arg => {
+      if (arg === 'https://www.mapbox.com/mock-style.css') {
+        return Promise.resolve({ body: mockUrlCss });
+      }
+      return Promise.reject(new Error('Unexpected URL.'));
+    });
+
+    batfishConfig = {
+      outputDirectory: tmp,
+      // Test URLs, globs, and absolute filenames.
+      stylesheets: [
+        'https://www.mapbox.com/mock-style.css',
+        path.join(__dirname, './fixtures/stylesheets/*.css'),
+        [
+          path.join(__dirname, './fixtures/stylesheets/inner/*.css'),
+          '!**/d.css'
+        ],
+        path.join(__dirname, './fixtures/stylesheets/inner/innermost/e.css')
+      ],
+      siteBasePath: '/mock/base/path'
+    };
+  });
+
+  afterEach(() => {
+    return del(tmp, { force: true });
+  });
+
+  test('writes expected CSS file', () => {
+    return compileStylesheets(batfishConfig)
+      .then(cssFilePath => pify(fs.readFile)(cssFilePath, 'utf8'))
+      .then(css => {
+        expect(css).toMatchSnapshot();
+      });
+  });
+
+  test('creates a source map', () => {
+    return compileStylesheets(batfishConfig).then(cssFilePath => {
+      expect(fs.existsSync(cssFilePath + '.map')).toBe(true);
+    });
+  });
+
+  test('copies url-referenced assets', () => {
+    return compileStylesheets(batfishConfig).then(() => {
+      expect(fs.readdirSync(tmp)).toEqual([
+        // Hashes of these woff2 files are based on the text content, so
+        // should stay constant between tests.
+        '24153b0b.woff2',
+        '675d172a.woff2',
+        '994d9585.woff2',
+        'batfish-styles.css',
+        'batfish-styles.css.map'
+      ]);
+    });
+  });
+
+  test('minimizes in production', () => {
+    return compileStylesheets(
+      Object.assign({}, batfishConfig, {
+        production: true
+      })
+    )
+      .then(cssFilePath => {
+        // [a-f0-9]{32} matches md5 hash.
+        expect(path.basename(cssFilePath)).toMatch(
+          /batfish-styles-[a-f0-9]{32}\.css$/
+        );
+        return pify(fs.readFile)(cssFilePath, 'utf8');
+      })
+      .then(css => {
+        expect(css).toMatchSnapshot();
+      });
+  });
+});
diff --git a/test/create-webpack-config-base.test.js b/test/create-webpack-config-base.test.js
new file mode 100644
index 0000000..d78b3b7
--- /dev/null
+++ b/test/create-webpack-config-base.test.js
@@ -0,0 +1,106 @@
+'use strict';
+
+const _ = require('lodash');
+const path = require('path');
+const createWebpackConfigBase = require('../src/node/create-webpack-config-base');
+const writeContextModule = require('../src/node/write-context-module');
+const validateConfig = require('../src/node/validate-config');
+const projectRootSerializer = require('./test-util/project-root-serializer');
+
+jest.mock('../src/node/write-context-module', () => {
+  return jest.fn(() => Promise.resolve('fake/batfish-context.js'));
+});
+
+// Mock mkdirp so validateConfig does not create a temporary directory.
+jest.mock('mkdirp', () => ({ sync: jest.fn() }));
+
+expect.addSnapshotSerializer(projectRootSerializer);
+
+function createBatfishConfig(options) {
+  return validateConfig(
+    Object.assign(
+      {
+        pagesDirectory: path.join(__dirname, './fixtures/get-pages-data')
+      },
+      options
+    )
+  );
+}
+
+describe('createWebpackConfigBase', () => {
+  afterEach(() => {
+    createWebpackConfigBase._clearCache();
+  });
+
+  test('default Webpack config', () => {
+    return createWebpackConfigBase(
+      createBatfishConfig()
+    ).then(webpackConfig => {
+      expect(webpackConfig).toMatchSnapshot();
+    });
+  });
+
+  test('default production Webpack config', () => {
+    return createWebpackConfigBase(
+      createBatfishConfig({ production: true })
+    ).then(webpackConfig => {
+      expect(webpackConfig).toMatchSnapshot();
+    });
+  });
+
+  test('writes a context module and adds it to the config', () => {
+    const batfishConfig = createBatfishConfig();
+    return createWebpackConfigBase(batfishConfig).then(webpackConfig => {
+      expect(writeContextModule).toHaveBeenCalledTimes(1);
+      expect(writeContextModule).toHaveBeenCalledWith(batfishConfig);
+      expect(
+        _.get(webpackConfig, ['resolve', 'alias', 'batfish-internal/context'])
+      ).toBe('fake/batfish-context.js');
+    });
+  });
+
+  test('with all Batfish config options that make a difference', () => {
+    const batfishConfig = createBatfishConfig({
+      jsxtremeMarkdownOptions: {
+        prependJs: [
+          `const add = (x, y) => x + y;`,
+          `import egg from 'chicken';`
+        ],
+        remarkPlugins: [
+          function remarkPluginOne() {},
+          function remarkPluginTwo() {}
+        ]
+      },
+      babelPresets: [
+        function babelPresetOne() {},
+        function babelPresetTwo() {}
+      ],
+      babelPlugins: [
+        function babelPluginOne() {},
+        function babelPluginTwo() {}
+      ],
+      fileLoaderExtensions: ['txt', 'config'],
+      siteBasePath: '/site/base/path/',
+      verbose: true,
+      babelExclude: /node_modules\/nothing/,
+      webpackLoaders: [
+        {
+          test: /\.jpg$/,
+          use: 'jpg-loader'
+        },
+        {
+          test: /\.png$/,
+          use: 'png-loader'
+        }
+      ],
+      applicationWrapperPath: path.join(
+        __dirname,
+        './fixtures/empty-component.js'
+      ),
+      temporaryDirectory: path.join(__dirname, './fake/temporary/directory')
+    });
+    return createWebpackConfigBase(batfishConfig).then(webpackConfig => {
+      expect(webpackConfig).toMatchSnapshot();
+    });
+  });
+});
diff --git a/test/create-webpack-config-client.test.js b/test/create-webpack-config-client.test.js
new file mode 100644
index 0000000..e409b55
--- /dev/null
+++ b/test/create-webpack-config-client.test.js
@@ -0,0 +1,73 @@
+'use strict';
+
+const path = require('path');
+const createWebpackConfigClient = require('../src/node/create-webpack-config-client');
+const createWebpackConfigBase = require('../src/node/create-webpack-config-base');
+const validateConfig = require('../src/node/validate-config');
+const projectRootSerializer = require('./test-util/project-root-serializer');
+
+jest.mock('../src/node/create-webpack-config-base', () => {
+  return jest.fn(() => Promise.resolve({ createdWebpackConfigBase: true }));
+});
+
+// Mock mkdirp so validateConfig does not create a temporary directory.
+jest.mock('mkdirp', () => ({ sync: jest.fn() }));
+
+expect.addSnapshotSerializer(projectRootSerializer);
+
+function createBatfishConfig(options) {
+  return validateConfig(
+    Object.assign(
+      {
+        pagesDirectory: path.join(__dirname, './fixtures/get-pages-data')
+      },
+      options
+    )
+  );
+}
+
+describe('createWebpackConfigClient', () => {
+  test('creates a base config', () => {
+    const batfishConfig = createBatfishConfig();
+    return createWebpackConfigClient(batfishConfig).then(webpackConfig => {
+      expect(createWebpackConfigBase).toHaveBeenCalledTimes(1);
+      expect(createWebpackConfigBase).toHaveBeenCalledWith(batfishConfig);
+      expect(webpackConfig.createdWebpackConfigBase).toBe(true);
+    });
+  });
+
+  test('default Webpack config', () => {
+    return createWebpackConfigClient(
+      createBatfishConfig()
+    ).then(webpackConfig => {
+      expect(webpackConfig).toMatchSnapshot();
+    });
+  });
+
+  test('default production Webpack config', () => {
+    return createWebpackConfigClient(
+      createBatfishConfig({ production: true })
+    ).then(webpackConfig => {
+      expect(webpackConfig).toMatchSnapshot();
+    });
+  });
+
+  test('with all Batfish config options that make a difference', () => {
+    const batfishConfig = createBatfishConfig({
+      includePromisePolyfill: false,
+      vendorModules: ['pigman', 'revenge-of-pigman'],
+      outputDirectory: path.join(__dirname, './fake/output/directory'),
+      inlineJs: [
+        { filename: path.join(__dirname, './sea-creatures.js') },
+        { filename: path.join(__dirname, './land-creatures.js') }
+      ],
+      webpackConfigClientTransform: x => {
+        x.underwentClientTransform = true;
+        return x;
+      }
+    });
+    return createWebpackConfigClient(batfishConfig).then(webpackConfig => {
+      expect(webpackConfig).toMatchSnapshot();
+    });
+  });
+});
diff --git a/test/create-webpack-config-static.test.js b/test/create-webpack-config-static.test.js
new file mode 100644
index 0000000..30436e3
--- /dev/null
+++ b/test/create-webpack-config-static.test.js
@@ -0,0 +1,59 @@
+'use strict';
+
+const path = require('path');
+const createWebpackConfigStatic = require('../src/node/create-webpack-config-static');
+const createWebpackConfigBase = require('../src/node/create-webpack-config-base');
+const validateConfig = require('../src/node/validate-config');
+const projectRootSerializer = require('./test-util/project-root-serializer');
+
+jest.mock('../src/node/create-webpack-config-base', () => {
+  return jest.fn(() => Promise.resolve({ createdWebpackConfigBase: true }));
+});
+
+// Mock mkdirp so validateConfig does not create a temporary directory.
+jest.mock('mkdirp', () => ({ sync: jest.fn() }));
+
+expect.addSnapshotSerializer(projectRootSerializer);
+
+function createBatfishConfig(options) {
+  return validateConfig(
+    Object.assign(
+      {
+        pagesDirectory: path.join(__dirname, './fixtures/get-pages-data')
+      },
+      options
+    )
+  );
+}
+
+describe('createWebpackConfigStatic', () => {
+  test('creates a base config', () => {
+    const batfishConfig = createBatfishConfig();
+    return createWebpackConfigStatic(batfishConfig).then(webpackConfig => {
+      expect(createWebpackConfigBase).toHaveBeenCalledTimes(1);
+      expect(createWebpackConfigBase).toHaveBeenCalledWith(batfishConfig);
+      expect(webpackConfig.createdWebpackConfigBase).toBe(true);
+    });
+  });
+
+  test('default Webpack config', () => {
+    return createWebpackConfigStatic(
+      createBatfishConfig()
+    ).then(webpackConfig => {
+      expect(webpackConfig).toMatchSnapshot();
+    });
+  });
+
+  test('with all Batfish config options that make a difference', () => {
+    const batfishConfig = createBatfishConfig({
+      webpackStaticIgnore: /stuff\/to\/ignore/,
+      webpackConfigStaticTransform: x => {
+        x.underwentStaticTransform = true;
+        return x;
+      }
+    });
+    return createWebpackConfigStatic(batfishConfig).then(webpackConfig => {
+      expect(webpackConfig).toMatchSnapshot();
+    });
+  });
+});
diff --git a/test/find-matching-route.test.js b/test/find-matching-route.test.js
new file mode 100644
index 0000000..a767d60
--- /dev/null
+++ b/test/find-matching-route.test.js
@@ -0,0 +1,66 @@
+'use strict';
+
+const findMatchingRoute = require('../src/webpack/find-matching-route')
+  .findMatchingRoute;
+/* eslint-disable node/no-missing-require */
+// This gets the mock value defined below.
+const batfishContext = require('batfish-internal/context').batfishContext;
+/* eslint-enable node/no-missing-require */
+
+jest.mock(
+  'batfish-internal/context',
+  () => {
+    return {
+      batfishContext: {
+        routes: [
+          {
+            path: '/foo/',
+            getPage: () => Promise.resolve()
+          },
+          {
+            path: '/foo/bar/',
+            getPage: () => Promise.resolve()
+          },
+          // Deals with internal routing.
+          {
+            path: '/foo/bar/baz/',
+            getPage: () => Promise.resolve(),
+            internalRouting: true
+          },
+          // Deals with 404.
+          {
+            path: '/404/',
+            getPage: () => Promise.resolve(),
+            is404: true
+          }
+        ],
+        notFoundRoute: {
+          path: '/404/',
+          getPage: () => Promise.resolve(),
+          is404: true
+        }
+      }
+    };
+  },
+  { virtual: true }
+);
+
+describe('findMatchingRoute', () => {
+  test('exact match', () => {
+    expect(findMatchingRoute('/foo/')).toBe(batfishContext.routes[0]);
+  });
+
+  test('input lacks trailing slash', () => {
+    expect(findMatchingRoute('/foo/bar')).toBe(batfishContext.routes[1]);
+  });
+
+  test('route with internal routing matches sub-routes', () => {
+    expect(findMatchingRoute('/foo/bar/baz/woo')).toBe(
+      batfishContext.routes[2]
+    );
+  });
+
+  test('if no match is found, returns 404 route', () => {
+    expect(findMatchingRoute('/bar/')).toBe(batfishContext.notFoundRoute);
+  });
+});
diff --git a/test/fixtures/empty-component.js b/test/fixtures/empty-component.js
new file mode 100644
index 0000000..2a4979f
--- /dev/null
+++ b/test/fixtures/empty-component.js
@@ -0,0 +1,9 @@
+'use strict';
+
+const React = require('react');
+
+module.exports = class EmptyComponent extends React.Component {
+  render() {
+    return this.props.children;
+  }
+};
diff --git a/test/fixtures/stylesheets/a.css b/test/fixtures/stylesheets/a.css
new file mode 100644
index 0000000..a0dfda3
--- /dev/null
+++ b/test/fixtures/stylesheets/a.css
@@ -0,0 +1,6 @@
+@font-face {
+  font-family: 'a';
+  src: url('./fonts/a.woff2') format('woff2');
+}
+
+.a { color: pink; }
diff --git a/test/fixtures/stylesheets/b.css b/test/fixtures/stylesheets/b.css
new file mode 100644
index 0000000..e8a7e16
--- /dev/null
+++ b/test/fixtures/stylesheets/b.css
@@ -0,0 +1,1 @@
+.b { color: orange; }
diff --git a/test/fixtures/stylesheets/fonts/a.woff2 b/test/fixtures/stylesheets/fonts/a.woff2
new file mode 100644
index 0000000..7898192
--- /dev/null
+++ b/test/fixtures/stylesheets/fonts/a.woff2
@@ -0,0 +1,1 @@
+a
diff --git a/test/fixtures/stylesheets/fonts/c.woff2 b/test/fixtures/stylesheets/fonts/c.woff2
new file mode 100644
index 0000000..f2ad6c7
--- /dev/null
+++ b/test/fixtures/stylesheets/fonts/c.woff2
@@ -0,0 +1,1 @@
+c
diff --git a/test/fixtures/stylesheets/fonts/e.woff2 b/test/fixtures/stylesheets/fonts/e.woff2
new file mode 100644
index 0000000..d905d9d
--- /dev/null
+++ b/test/fixtures/stylesheets/fonts/e.woff2
@@ -0,0 +1,1 @@
+e
diff --git a/test/fixtures/stylesheets/inner/c.css b/test/fixtures/stylesheets/inner/c.css
new file mode 100644
index 0000000..63de447
--- /dev/null
+++ b/test/fixtures/stylesheets/inner/c.css
@@ -0,0 +1,6 @@
+@font-face {
+  font-family: 'c';
+  src: url('../fonts/c.woff2') format('woff2');
+}
+
+.c { color: purple; }
diff --git a/test/fixtures/stylesheets/inner/d.css b/test/fixtures/stylesheets/inner/d.css
new file mode 100644
index 0000000..ed24f7b
--- /dev/null
+++ b/test/fixtures/stylesheets/inner/d.css
@@ -0,0 +1,1 @@
+.d { color: blue; }
diff --git a/test/fixtures/stylesheets/inner/innermost/e.css b/test/fixtures/stylesheets/inner/innermost/e.css
new file mode 100644
index 0000000..37db2c8
--- /dev/null
+++ b/test/fixtures/stylesheets/inner/innermost/e.css
@@ -0,0 +1,6 @@
+@font-face {
+  font-family: 'e';
+  src: url('../../fonts/e.woff2') format('woff2');
+}
+
+.e { color: grey; }
diff --git a/test/postcss-absolute-urls.test.js b/test/postcss-absolute-urls.test.js
new file mode 100644
index 0000000..4f770e9
--- /dev/null
+++ b/test/postcss-absolute-urls.test.js
@@ -0,0 +1,30 @@
+'use strict';
+
+const postcss = require('postcss');
+const postcssAbsoluteUrls = require('../src/node/postcss-absolute-urls');
+
+describe('postcssAbsoluteUrls', () => {
+  test('works', () => {
+    const css = `
+      @font-face {
+        font-family: "Open Sans";
+        src: url("../fonts/OpenSans-Regular-webfont.woff2") format("woff2"),
+              url("/fonts/OpenSans-Regular-webfont.woff") format("woff");
+      }
+      body {
+        background: url('data:blergh');
+      }
+    `;
+
+    return postcss()
+      .use(
+        postcssAbsoluteUrls({
+          stylesheetUrl: 'https://www.fake-place.com/path/to/style.css'
+        })
+      )
+      .process(css)
+      .then(result => {
+        expect(result.css).toMatchSnapshot();
+      });
+  });
+});
diff --git a/test/route-to.test.js b/test/route-to.test.js
new file mode 100644
index 0000000..3912ef8
--- /dev/null
+++ b/test/route-to.test.js
@@ -0,0 +1,59 @@
+'use strict';
+
+const routeTo = require('../src/webpack/public/route-to').routeTo;
+const routeToPrefixed = require('../src/webpack/public/route-to')
+  .routeToPrefixed;
+
+jest.mock('../src/webpack/public/prefix-url', () => {
+  return {
+    prefixUrl: jest.fn(url => `prefixed/${url}`)
+  };
+});
+
+describe('routeTo', () => {
+  let routeToHandler = jest.fn();
+
+  afterEach(() => {
+    routeTo._clearRouteToHandler();
+  });
+
+  test('calls the handler', () => {
+    routeTo._setRouteToHandler(routeToHandler);
+    routeTo('foo/bar');
+    expect(routeToHandler).toHaveBeenCalledTimes(1);
+    expect(routeToHandler).toHaveBeenCalledWith('foo/bar');
+  });
+
+  test('once the handler is added called delayed URL', () => {
+    routeTo('foo/bar');
+    routeTo('foo/bar/baz');
+    expect(routeToHandler).toHaveBeenCalledTimes(0);
+    routeTo._setRouteToHandler(routeToHandler);
+    expect(routeToHandler).toHaveBeenCalledTimes(1);
+    expect(routeToHandler).toHaveBeenCalledWith('foo/bar');
+  });
+});
+
+describe('routeToPrefixed', () => {
+  let routeToHandler = jest.fn();
+
+  afterEach(() => {
+    routeTo._clearRouteToHandler();
+  });
+
+  test('calls the handler', () => {
+    routeTo._setRouteToHandler(routeToHandler);
+    routeToPrefixed('foo/bar');
+    expect(routeToHandler).toHaveBeenCalledTimes(1);
+    expect(routeToHandler).toHaveBeenCalledWith('prefixed/foo/bar');
+  });
+
+  test('once the handler is added called delayed URL', () => {
+    routeToPrefixed('foo/bar');
+    routeToPrefixed('foo/bar/baz');
+    expect(routeToHandler).toHaveBeenCalledTimes(0);
+    routeTo._setRouteToHandler(routeToHandler);
+    expect(routeToHandler).toHaveBeenCalledTimes(1);
+    expect(routeToHandler).toHaveBeenCalledWith('prefixed/foo/bar');
+  });
+});
diff --git a/test/router.test.js b/test/router.test.js
new file mode 100644
index 0000000..c10e1f4
--- /dev/null
+++ b/test/router.test.js
@@ -0,0 +1,366 @@
+/**
+ * @jest-environment jsdom
+ */
+/* eslint-disable node/no-missing-require */
+/* globals window */
+'use strict';
+
+const React = require('react');
+const shallow = require('enzyme').shallow;
+const mount = require('enzyme').mount;
+const toJson = require('enzyme-to-json').default;
+const Router = require('../src/webpack/router').Router;
+const routeTo = require('@mapbox/batfish/modules/route-to').routeTo;
+const _invokeRouteChangeStartCallbacks = require('@mapbox/batfish/modules/route-change-listeners')
+  ._invokeRouteChangeStartCallbacks;
+const _invokeRouteChangeEndCallbacks = require('@mapbox/batfish/modules/route-change-listeners')
+  ._invokeRouteChangeEndCallbacks;
+const scrollRestorer = require('@mapbox/scroll-restorer');
+const linkHijacker = require('@mapbox/link-hijacker');
+const linkToLocation = require('@mapbox/link-to-location');
+const findMatchingRoute = require('../src/webpack/find-matching-route')
+  .findMatchingRoute;
+
+jest.mock('../src/webpack/find-matching-route', () => {
+  return {
+    findMatchingRoute: jest.fn()
+  };
+});
+
+jest.mock(
+  '@mapbox/batfish/modules/route-to',
+  () => {
+    const routeTo = jest.fn();
+    routeTo._setRouteToHandler = jest.fn();
+    return { routeTo };
+  },
+  { virtual: true }
+);
+
+jest.mock(
+  '@mapbox/batfish/modules/prefix-url',
+  () => {
+    const prefixUrl = jest.fn();
+    prefixUrl._configure = jest.fn();
+    return { prefixUrl };
+  },
+  { virtual: true }
+);
+
+jest.mock(
+  '@mapbox/batfish/modules/route-change-listeners',
+  () => {
+    return {
+      _invokeRouteChangeStartCallbacks: jest.fn(() => Promise.resolve()),
+      _invokeRouteChangeEndCallbacks: jest.fn(() => Promise.resolve())
+    };
+  },
+  { virtual: true }
+);
+
+jest.mock(
+  'batfish-internal/context',
+  () => {
+    return {
+      batfishContext: {
+        selectedConfig: {
+          siteBasePath: 'mockSiteBasePath',
+          siteOrigin: 'mockSiteOrigin',
+          hijackLinks: true
+        }
+      }
+    };
+  },
+  { virtual: true }
+);
+
+jest.mock('@mapbox/scroll-restorer', () => {
+  return {
+    start: jest.fn(),
+    restoreScroll: jest.fn(),
+    getSavedScroll: jest.fn()
+  };
+});
+
+jest.mock('@mapbox/link-hijacker', () => {
+  return {
+    hijack: jest.fn()
+  };
+});
+
+jest.mock('@mapbox/link-to-location', () => {
+  return jest.fn();
+});
+
+describe('Router', () => {
+  function StartingComponent() {
+    return <div>magic</div>;
+  }
+  const pageProps = { isMagic: true };
+
+  beforeEach(() => {
+    Object.defineProperty(window.location, 'pathname', {
+      writable: true,
+      value: '/magic/'
+    });
+    Object.defineProperty(window.location, 'search', {
+      writable: true,
+      value: ''
+    });
+    Object.defineProperty(window.location, 'hash', {
+      writable: true,
+      value: ''
+    });
+    Object.defineProperty(window.location, 'assign', {
+      writable: true,
+      value: jest.fn()
+    });
+  });
+
+  test('renders the initial page', () => {
+    const wrapper = shallow(
+      React.createElement(Router, {
+        startingPath: '/magic/',
+        startingComponent: StartingComponent,
+        pageProps: pageProps
+      })
+    );
+    expect(toJson(wrapper)).toMatchSnapshot();
+  });
+
+  test('on mount, starts the scroll restorer and tries to restore prior scroll', () => {
+    mount(
+      React.createElement(Router, {
+        startingPath: '/magic/',
+        startingComponent: StartingComponent,
+        pageProps: pageProps
+      })
+    );
+    expect(scrollRestorer.start).toHaveBeenCalledTimes(1);
+    expect(scrollRestorer.start).toHaveBeenCalledWith({ autoRestore: false });
+    expect(scrollRestorer.restoreScroll).toHaveBeenCalledTimes(1);
+  });
+
+  test('on mount, sets the route handler for routeTo', () => {
+    const wrapper = mount(
+      React.createElement(Router, {
+        startingPath: '/magic/',
+        startingComponent: StartingComponent,
+        pageProps: pageProps
+      })
+    );
+    expect(routeTo._setRouteToHandler).toHaveBeenCalledTimes(1);
+    expect(routeTo._setRouteToHandler).toHaveBeenCalledWith(
+      wrapper.instance().routeTo
+    );
+  });
+
+  test('on mount, initializes link hijacking', () => {
+    const wrapper = mount(
+      React.createElement(Router, {
+        startingPath: '/magic/',
+        startingComponent: StartingComponent,
+        pageProps: pageProps
+      })
+    );
+    expect(linkHijacker.hijack).toHaveBeenCalledTimes(1);
+    expect(linkHijacker.hijack.mock.calls[0][0]).toHaveProperty('skipFilter');
+    expect(linkHijacker.hijack.mock.calls[0][1]).toBe(
+      wrapper.instance().routeTo
+    );
+  });
+
+  test('on mount, adds a popstate listener that changes pages', () => {
+    Object.defineProperty(window, 'addEventListener', {
+      writable: true,
+      value: jest.fn()
+    });
+    const wrapper = mount(
+      React.createElement(Router, {
+        startingPath: '/magic/',
+        startingComponent: StartingComponent,
+        pageProps: pageProps
+      })
+    );
+    expect(window.addEventListener).toHaveBeenCalledTimes(1);
+    expect(window.addEventListener.mock.calls[0][0]).toBe('popstate');
+
+    Object.defineProperty(window.location, 'pathname', {
+      writable: true,
+      value: '/magic/'
+    });
+    Object.defineProperty(window.location, 'search', {
+      writable: true,
+      value: '?foo=bar'
+    });
+    Object.defineProperty(window.location, 'hash', {
+      writable: true,
+      value: '#baz'
+    });
+    const instance = wrapper.instance();
+    jest.spyOn(instance, 'changePage').mockImplementation(() => {});
+    const mockEvent = {
+      preventDefault: jest.fn()
+    };
+    expect(mockEvent.preventDefault).toHaveBeenCalledTimes(0);
+    expect(instance.changePage).toHaveBeenCalledTimes(0);
+    window.addEventListener.mock.calls[0][1](mockEvent);
+    expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
+    expect(instance.changePage).toHaveBeenCalledTimes(1);
+    expect(instance.changePage).toHaveBeenCalledWith({
+      hash: '#baz',
+      pathname: '/magic/',
+      search: '?foo=bar'
+    });
+  });
+
+  test('on mount, resets the location state to capture hash and search', () => {
+    const wrapper = shallow(
+      React.createElement(Router, {
+        startingPath: '/magic/',
+        startingComponent: StartingComponent,
+        pageProps: pageProps
+      })
+    );
+    expect(wrapper.find('StartingComponent').props().location).toEqual({
+      pathname: '/magic/',
+      search: '',
+      hash: ''
+    });
+
+    Object.defineProperty(window.location, 'pathname', {
+      writable: true,
+      value: '/magic/'
+    });
+    Object.defineProperty(window.location, 'search', {
+      writable: true,
+      value: '?foo=bar'
+    });
+    Object.defineProperty(window.location, 'hash', {
+      writable: true,
+      value: '#baz'
+    });
+    wrapper.instance().componentDidMount();
+    wrapper.update();
+    expect(wrapper.find('StartingComponent').props().location).toEqual({
+      pathname: '/magic/',
+      search: '?foo=bar',
+      hash: '#baz'
+    });
+  });
+
+  test('instance.routeTo changes pages programmatically for Batfish-matching routes', () => {
+    const wrapper = mount(
+      React.createElement(Router, {
+        startingPath: '/magic/',
+        startingComponent: StartingComponent,
+        pageProps: pageProps
+      })
+    );
+    linkToLocation.mockReturnValue({ pathname: '/horse/' });
+    findMatchingRoute.mockReturnValue({});
+    const instance = wrapper.instance();
+    instance.changePage = jest.fn();
+    instance.routeTo('foo');
+    expect(instance.changePage).toHaveBeenCalledTimes(1);
+    expect(instance.changePage).toHaveBeenCalledWith(
+      { pathname: '/horse/' },
+      {
+        pushState: true,
+        scrollToTop: true
+      }
+    );
+  });
+
+  test('instance.routeTo does not reset scroll after changing pages if pathname has not changed and there is a hash in the next URL', () => {
+    const wrapper = mount(
+      React.createElement(Router, {
+        startingPath: '/magic/',
+        startingComponent: StartingComponent,
+        pageProps: pageProps
+      })
+    );
+    linkToLocation.mockReturnValue({ pathname: '/magic/', hash: '#dog' });
+    findMatchingRoute.mockReturnValue({});
+    const instance = wrapper.instance();
+    instance.changePage = jest.fn();
+    instance.routeTo('foo');
+    expect(instance.changePage).toHaveBeenCalledTimes(1);
+    expect(instance.changePage).toHaveBeenCalledWith(
+      { pathname: '/magic/', hash: '#dog' },
+      {
+        pushState: true,
+        scrollToTop: false
+      }
+    );
+  });
+
+  test('instance.routeTo redirects for non-Batfish-matching pages', () => {
+    const wrapper = mount(
+      React.createElement(Router, {
+        startingPath: '/magic/',
+        startingComponent: StartingComponent,
+        pageProps: pageProps
+      })
+    );
+    linkToLocation.mockReturnValue({ pathname: '/not/known/page' });
+    findMatchingRoute.mockReturnValue({ is404: true });
+    const instance = wrapper.instance();
+    instance.changePage = jest.fn();
+    instance.routeTo('/not/known/page');
+    expect(instance.changePage).toHaveBeenCalledTimes(0);
+    expect(window.location.assign).toHaveBeenCalledTimes(1);
+    expect(window.location.assign).toHaveBeenCalledWith('/not/known/page');
+  });
+
+  test.only(
+    'instance.changePage invokes change-start and change-end callbacks',
+    () => {
+      const wrapper = shallow(
+        React.createElement(Router, {
+          startingPath: '/magic/',
+          startingComponent: StartingComponent,
+          pageProps: pageProps
+        })
+      );
+      const instance = wrapper.instance();
+      const mockPageModule = {
+        component: function Mockery() {
+          return <div>mockery</div>;
+        },
+        props: {}
+      };
+      const mockRoute = {
+        getPage: () => Promise.resolve(mockPageModule),
+        path: '/mock/route/'
+      };
+      const mockNextLocation = {
+        pathname: '/mock/route/',
+        hash: '#foo'
+      };
+      findMatchingRoute.mockImplementation(pathname => {
+        if (pathname === mockNextLocation.pathname) {
+          return mockRoute;
+        } else {
+          throw new Error(`Oops. Unknown pathname ${pathname}`);
+        }
+      });
+      const changer = instance.changePage(mockNextLocation);
+      expect(findMatchingRoute).toHaveBeenCalledTimes(1);
+      expect(findMatchingRoute).toHaveBeenCalledWith(mockNextLocation.pathname);
+      expect(_invokeRouteChangeStartCallbacks).toHaveBeenCalledTimes(1);
+      expect(_invokeRouteChangeStartCallbacks).toHaveBeenCalledWith(
+        mockNextLocation.pathname
+      );
+      expect(_invokeRouteChangeEndCallbacks).toHaveBeenCalledTimes(0);
+      wrapper.update();
+      return changer.then(() => {
+        expect(toJson(wrapper)).toMatchSnapshot();
+        expect(_invokeRouteChangeEndCallbacks).toHaveBeenCalledTimes(1);
+        expect(_invokeRouteChangeEndCallbacks).toHaveBeenCalledWith(
+          mockNextLocation.pathname
+        );
+      });
+    }
+  );
+});
diff --git a/test/test-util/project-root-serializer.js b/test/test-util/project-root-serializer.js
new file mode 100644
index 0000000..91cd22c
--- /dev/null
+++ b/test/test-util/project-root-serializer.js
@@ -0,0 +1,19 @@
+'use strict';
+
+const path = require('path');
+
+const absolutePathRegExp = new RegExp(
+  path.join(__dirname, '../..').replace(/\//g, '\\\\{0,3}/'),
+  'g'
+);
+
+// A custom Jest serializer to make absolute paths in snapshots start with
+// <PROJECT_ROOT>, instead.
+module.exports = {
+  print: (value, serialize) => {
+    return serialize(value).replace(absolutePathRegExp, '<PROJECT_ROOT>');
+  },
+  test: value => {
+    return absolutePathRegExp.test(value);
+  }
+};
diff --git a/test/validate-config.test.js b/test/validate-config.test.js
index 483cff0..a7ae56e 100644
--- a/test/validate-config.test.js
+++ b/test/validate-config.test.js
@@ -4,8 +4,12 @@ const mkdirp = require('mkdirp');
 const del = require('del');
 const path = require('path');
 const fs = require('fs');
+const stripAnsi = require('strip-ansi');
 const validateConfig = require('../src/node/validate-config');
 const errorTypes = require('../src/node/error-types');
+const projectRootSerializer = require('./test-util/project-root-serializer');
+
+expect.addSnapshotSerializer(projectRootSerializer);
 
 jest.mock('mkdirp', () => {
   return {
@@ -22,43 +26,68 @@ jest.mock('del', () => {
 describe('validateConfig', () => {
   const projectDirectory = '/my-project';
 
-  beforeAll(() => {
-    // Allow for the test projectDirectory to work when checking if files exist.
+  beforeEach(() => {
     const realFsExistsSync = fs.existsSync;
-    fs.existsSync = input => {
+    // Allow for the test projectDirectory to work when checking if files exist.
+    jest.spyOn(fs, 'existsSync').mockImplementation(input => {
       if (input === `${projectDirectory}/src/pages`) {
         return true;
       }
       return realFsExistsSync(input);
-    };
+    });
+  });
+
+  afterEach(() => {
+    fs.existsSync.mockRestore();
   });
 
   test('defaults', () => {
     const config = validateConfig(undefined, projectDirectory);
-    // Make applicationWrapperPath a relative path, not absolute
-    config.applicationWrapperPath = path.relative(
-      path.dirname(path.join(__dirname, '../src/node/validate-config')),
-      config.applicationWrapperPath
-    );
     expect(config).toMatchSnapshot();
   });
 
-  test('invalid configuration properties fails', () => {
-    const invalidPropertyConfig = {
-      port: 8080,
-      fakePort: 1337
-    };
-    expect(() => validateConfig(invalidPropertyConfig)).toThrow(
-      errorTypes.ConfigValidationErrors
-    );
+  test('non-existent configuration properties fails', () => {
     const invalidPropertiesConfig = {
       fakeProduction: true,
       fakePort: 1337,
       fakeExternalStylesheets: null
     };
-    expect(() => validateConfig(invalidPropertiesConfig)).toThrow(
-      errorTypes.ConfigValidationErrors
-    );
+    expect.hasAssertions();
+    try {
+      validateConfig(invalidPropertiesConfig);
+    } catch (error) {
+      expect(error instanceof errorTypes.ConfigValidationError).toBe(true);
+      expect(error.messages.map(stripAnsi).sort()).toEqual([
+        'fakeExternalStylesheets is not a valid configuration property.',
+        'fakePort is not a valid configuration property.',
+        'fakeProduction is not a valid configuration property.'
+      ]);
+    }
+  });
+
+  test('invalid configuration values fail', () => {
+    // This is not a comprehensive test of all possible failures.
+    // It tests enough of configSchema to verify that it is working as expected.
+    const invalidValuesConfig = {
+      siteBasePath: 7,
+      browserslist: ['foo', ['bar', 'baz']],
+      dataSelectors: {
+        high: 'up'
+      },
+      production: 'please'
+    };
+    expect.hasAssertions();
+    try {
+      validateConfig(invalidValuesConfig);
+    } catch (error) {
+      expect(error instanceof errorTypes.ConfigValidationError).toBe(true);
+      expect(error.messages.map(stripAnsi).sort()).toEqual([
+        'browserslist must be a string or array of strings.',
+        'dataSelectors must be an object whose values are functions.',
+        'production must be a boolean.',
+        'siteBasePath must be a string.'
+      ]);
+    }
   });
 
   test('temporaryDirectory is created and cleared', () => {
@@ -72,42 +101,6 @@ describe('validateConfig', () => {
     );
   });
 
-  test('non-absolute pagesDirectory fails', () => {
-    const config = {
-      pagesDirectory: '../some/directory'
-    };
-    expect(() => validateConfig(config, projectDirectory)).toThrow(
-      errorTypes.ConfigValidationErrors
-    );
-  });
-
-  test('non-absolute outputDirectory fails', () => {
-    const config = {
-      outputDirectory: '../some/directory'
-    };
-    expect(() => validateConfig(config, projectDirectory)).toThrow(
-      errorTypes.ConfigValidationErrors
-    );
-  });
-
-  test('non-absolute applicationWrapperPath fails', () => {
-    const config = {
-      applicationWrapperPath: '../some/directory.wrapper.js'
-    };
-    expect(() => validateConfig(config, projectDirectory)).toThrow(
-      errorTypes.ConfigValidationErrors
-    );
-  });
-
-  test('non-absolute temporary fails', () => {
-    const config = {
-      temporaryDirectory: '../some/directory'
-    };
-    expect(() => validateConfig(config, projectDirectory)).toThrow(
-      errorTypes.ConfigValidationErrors
-    );
-  });
-
   test('processed siteOrigin does not end with a slash', () => {
     expect(
       validateConfig(
@@ -115,8 +108,8 @@ describe('validateConfig', () => {
           siteOrigin: 'https://www.mapbox.com/'
         },
         projectDirectory
-      ).siteOrigin
-    ).toBe('https://www.mapbox.com');
+      )
+    ).toHaveProperty('siteOrigin', 'https://www.mapbox.com');
 
     expect(
       validateConfig(
@@ -124,8 +117,8 @@ describe('validateConfig', () => {
           siteOrigin: 'https://www.mapbox.com'
         },
         projectDirectory
-      ).siteOrigin
-    ).toBe('https://www.mapbox.com');
+      )
+    ).toHaveProperty('siteOrigin', 'https://www.mapbox.com');
   });
 
   test('processed siteBasePath does not end with a slash unless it is only a slash', () => {
@@ -135,8 +128,8 @@ describe('validateConfig', () => {
           siteBasePath: 'about/team/'
         },
         projectDirectory
-      ).siteBasePath
-    ).toBe('/about/team');
+      )
+    ).toHaveProperty('siteBasePath', '/about/team');
 
     expect(
       validateConfig(
@@ -144,8 +137,8 @@ describe('validateConfig', () => {
           siteBasePath: 'about/team'
         },
         projectDirectory
-      ).siteBasePath
-    ).toBe('/about/team');
+      )
+    ).toHaveProperty('siteBasePath', '/about/team');
 
     expect(
       validateConfig(
@@ -153,8 +146,8 @@ describe('validateConfig', () => {
           siteBasePath: '/'
         },
         projectDirectory
-      ).siteBasePath
-    ).toBe('/');
+      )
+    ).toHaveProperty('siteBasePath', '/');
   });
 
   test('processed siteBasePath always starts with a slash', () => {
@@ -164,8 +157,8 @@ describe('validateConfig', () => {
           siteBasePath: 'about/team/'
         },
         projectDirectory
-      ).siteBasePath
-    ).toBe('/about/team');
+      )
+    ).toHaveProperty('siteBasePath', '/about/team');
 
     expect(
       validateConfig(
@@ -173,8 +166,8 @@ describe('validateConfig', () => {
           siteBasePath: '/about/team'
         },
         projectDirectory
-      ).siteBasePath
-    ).toBe('/about/team');
+      )
+    ).toHaveProperty('siteBasePath', '/about/team');
 
     expect(
       validateConfig(
@@ -182,8 +175,8 @@ describe('validateConfig', () => {
           siteBasePath: '/'
         },
         projectDirectory
-      ).siteBasePath
-    ).toBe('/');
+      )
+    ).toHaveProperty('siteBasePath', '/');
   });
 
   test('fileLoaderExtensions transform function', () => {
@@ -193,7 +186,37 @@ describe('validateConfig', () => {
           fileLoaderExtensions: defaults => defaults.concat('svg')
         },
         projectDirectory
-      ).fileLoaderExtensions
-    ).toMatchSnapshot();
+      )
+    ).toHaveProperty('fileLoaderExtensions', [
+      'jpeg',
+      'jpg',
+      'png',
+      'gif',
+      'webp',
+      'mp4',
+      'webm',
+      'woff',
+      'woff2',
+      'svg'
+    ]);
+  });
+
+  test('stylesheets file paths must exist', () => {
+    expect.hasAssertions();
+    try {
+      validateConfig({
+        stylesheets: [
+          'https://www.mapbox.com/chunk-lite.css',
+          path.join(__dirname, 'src/pages/**/*.css'),
+          path.join(__dirname, 'does-not-exist.css')
+        ]
+      });
+    } catch (error) {
+      expect(error instanceof errorTypes.ConfigValidationError).toBe(true);
+      expect(error.messages.length).toBe(1);
+      expect(stripAnsi(error.messages[0])).toMatch(
+        'does-not-exist.css does not point to an existing file'
+      );
+    }
   });
 });
diff --git a/test/write-context-module.test.js b/test/write-context-module.test.js
new file mode 100644
index 0000000..09dc1b4
--- /dev/null
+++ b/test/write-context-module.test.js
@@ -0,0 +1,148 @@
+'use strict';
+
+const _ = require('lodash');
+const del = require('del');
+const pify = require('pify');
+const fs = require('fs');
+const mkdirp = require('mkdirp');
+const tempy = require('tempy');
+const writeContextModule = require('../src/node/write-context-module');
+const writePageModule = require('../src/node/write-page-module');
+const writeDataModules = require('../src/node/write-data-modules');
+const getPagesData = require('../src/node/get-pages-data');
+
+jest.mock('../src/node/write-page-module', () => {
+  return jest.fn((config, pageData) =>
+    Promise.resolve(`module/file/path/for${pageData.filePath}`)
+  );
+});
+
+jest.mock('../src/node/write-data-modules', () => {
+  return jest.fn(() => Promise.resolve());
+});
+
+jest.mock('../src/node/get-pages-data', () => {
+  return jest.fn();
+});
+
+describe('writeContextModule', () => {
+  let tmp;
+
+  const createAndReadContextModule = (batfishConfig = {}) => {
+    tmp = tempy.directory();
+    // _.defaults keeps the object reference the same so we can use that for
+    // tests below.
+    _.defaults(batfishConfig, {
+      temporaryDirectory: tmp,
+      pagesDirectory: 'fake/pages/directory',
+      siteBasePath: '/fake/site/base/path',
+      siteOrigin: 'https://www.fake-origin.com',
+      hijackLinks: true
+    });
+    return (
+      pify(mkdirp)(tmp)
+        .then(() => writeContextModule(batfishConfig))
+        // We cannot import this file because it includes imports
+        .then(filePath => pify(fs.readFile)(filePath, 'utf8'))
+    );
+  };
+
+  afterEach(() => {
+    if (tmp) {
+      return del(tmp, { force: true }).then(() => {
+        tmp = null;
+      });
+    }
+  });
+
+  test('without 404', () => {
+    const pagesData = {
+      '/one/': {
+        filePath: 'fake/pages/directory/one.js',
+        path: '/one/',
+        frontMatter: {}
+      },
+      '/two/': {
+        filePath: 'fake/pages/directory/two/index.md',
+        path: '/two/',
+        frontMatter: {
+          title: 'Page two',
+          isGreat: true
+        }
+      },
+      '/three/four/': {
+        filePath: 'fake/pages/directory/three/four/index.js',
+        path: '/three/four/',
+        frontMatter: {
+          title: 'Another page',
+          horseNames: ['gerald', 'rose']
+        }
+      }
+    };
+    getPagesData.mockReturnValue(Promise.resolve(pagesData));
+
+    const config = {};
+    return createAndReadContextModule(config).then(result => {
+      expect(result).toMatchSnapshot();
+      expect(writePageModule).toHaveBeenCalledTimes(3);
+      expect(writePageModule).toHaveBeenCalledWith(config, {
+        filePath: 'fake/pages/directory/one.js',
+        path: '/one/',
+        frontMatter: {}
+      });
+      expect(writePageModule).toHaveBeenCalledWith(config, {
+        filePath: 'fake/pages/directory/two/index.md',
+        path: '/two/',
+        frontMatter: {
+          title: 'Page two',
+          isGreat: true
+        }
+      });
+      expect(writePageModule).toHaveBeenCalledWith(config, {
+        filePath: 'fake/pages/directory/three/four/index.js',
+        path: '/three/four/',
+        frontMatter: {
+          title: 'Another page',
+          horseNames: ['gerald', 'rose']
+        }
+      });
+      expect(writeDataModules).toHaveBeenCalledTimes(1);
+      expect(writeDataModules).toHaveBeenCalledWith(config, {
+        pages: _.values(pagesData)
+      });
+    });
+  });
+
+  test('with 404 and some config variation', () => {
+    getPagesData.mockReturnValue(
+      Promise.resolve({
+        '/one/': {
+          filePath: 'fake/pages/directory/one.js',
+          path: '/one/',
+          frontMatter: {}
+        },
+        '/three/four/': {
+          filePath: 'fake/pages/directory/three/four/index.js',
+          path: '/three/four/',
+          frontMatter: {
+            title: 'Another page',
+            horseNames: ['gerald', 'rose']
+          }
+        },
+        '/404/': {
+          filePath: 'fake/pages/directory/404.js',
+          path: '/404/',
+          frontMatter: {}
+        }
+      })
+    );
+
+    return createAndReadContextModule({
+      siteBasePath: '/different/base/path',
+      siteOrigin: '',
+      hijackLinks: false
+    }).then(result => {
+      expect(result).toMatchSnapshot();
+    });
+  });
+});
diff --git a/test/write-data-modules.test.js b/test/write-data-modules.test.js
new file mode 100644
index 0000000..a42df41
--- /dev/null
+++ b/test/write-data-modules.test.js
@@ -0,0 +1,97 @@
+'use strict';
+
+const del = require('del');
+const pify = require('pify');
+const path = require('path');
+const mkdirp = require('mkdirp');
+const tempy = require('tempy');
+const writeDataModules = require('../src/node/write-data-modules');
+
+describe('writeDataModules', () => {
+  let tmp;
+
+  const createAndReadDataModules = (batfishConfig, siteData) => {
+    tmp = tempy.directory();
+    batfishConfig.temporaryDirectory = tmp;
+    return pify(mkdirp)(tmp)
+      .then(() => writeDataModules(batfishConfig, siteData))
+      .then(filePaths => {
+        const result = new Map();
+        filePaths.forEach(p => {
+          result.set(path.relative(tmp, p), require(p));
+        });
+        return result;
+      });
+  };
+
+  afterEach(() => {
+    if (tmp) {
+      return del(tmp, { force: true }).then(() => {
+        tmp = null;
+      });
+    }
+  });
+
+  test('no data selectors', () => {
+    return createAndReadDataModules({}).then(result => {
+      expect(result.size).toBe(0);
+    });
+  });
+
+  test('one data selector', () => {
+    const siteData = {
+      horseNames: ['jack', 'jill', 'biff']
+    };
+    return createAndReadDataModules(
+      {
+        dataSelectors: {
+          namesOfHorses: siteData => siteData.horseNames.sort()
+        }
+      },
+      siteData
+    ).then(result => {
+      expect(result.size).toBe(1);
+      expect(result.get('data/names-of-horses.js')).toEqual([
+        'biff',
+        'jack',
+        'jill'
+      ]);
+    });
+  });
+
+  test('three data selectors', () => {
+    const siteData = {
+      horseNames: ['jack', 'jill', 'biff'],
+      pigNames: ['ed', 'ron']
+    };
+    return createAndReadDataModules(
+      {
+        dataSelectors: {
+          namesOfHorses: siteData => siteData.horseNames.sort(),
+          notFromSiteData: () => ({ one: 1, two: 2 }),
+          allNames: siteData =>
+            siteData.horseNames.concat(siteData.pigNames).sort()
+        }
+      },
+      siteData
+    ).then(result => {
+      expect(result.size).toBe(3);
+      expect(result.get('data/names-of-horses.js')).toEqual([
+        'biff',
+        'jack',
+        'jill'
+      ]);
+      expect(result.get('data/not-from-site-data.js')).toEqual({
+        one: 1,
+        two: 2
+      });
+      expect(result.get('data/all-names.js')).toEqual([
+        'biff',
+        'ed',
+        'jack',
+        'jill',
+        'ron'
+      ]);
+    });
+  });
+});
diff --git a/test/write-page-module.test.js b/test/write-page-module.test.js
index a3494ed..3af1166 100644
--- a/test/write-page-module.test.js
+++ b/test/write-page-module.test.js
@@ -8,26 +8,25 @@ const tempy = require('tempy');
 const writePageModule = require('../src/node/write-page-module');
 
 describe('writePageModule', () => {
-  const tmp = tempy.directory();
+  let tmp;
   const pageComponentPath = path.join(
     __dirname,
     './fixtures/write-page-module/page-component-module.js'
   );
 
   const createAndReadPageModule = (batfishConfig, pageData) => {
+    tmp = tempy.directory();
     return pify(mkdirp)(tmp)
-      .then(() => {
-        return writePageModule(batfishConfig, pageData);
-      })
-      .then(filePath => {
-        // Because this uses require, we need to avoid the require cache by
-        // creating files with different names for each test.
-        return require(filePath);
-      });
+      .then(() => writePageModule(batfishConfig, pageData))
+      .then(filePath => require(filePath));
   };
 
   afterEach(() => {
-    return del(tmp, { force: true });
+    if (tmp) {
+      return del(tmp, { force: true }).then(() => {
+        tmp = null;
+      });
+    }
   });
 
   test('home without any front matter', () => {