about summary refs log tree commit diff
path: root/test
diff options
context:
space:
mode:
authorHeikki Kallasjoki <fis@zem.fi>2018-12-07 00:17:09 +0000
committerHeikki Kallasjoki <fis+github@zem.fi>2018-12-12 19:10:51 +0000
commit8569c2c85bec19daa36e013448399c42cf75a257 (patch)
tree7444488e4e5ae5c98f575a2eafd522c6427ffbdf /test
parentd03dea4d7dbfb62ed0c3c21cdd9ad350f0526432 (diff)
downloadnano-exporter-8569c2c85bec19daa36e013448399c42cf75a257.tar.gz
nano-exporter-8569c2c85bec19daa36e013448399c42cf75a257.tar.xz
nano-exporter-8569c2c85bec19daa36e013448399c42cf75a257.zip
Add a simple test harness and tests for the cpu collector.
Diffstat (limited to 'test')
-rw-r--r--test/Makefile47
-rw-r--r--test/cpu_test.c76
-rw-r--r--test/harness.c241
-rw-r--r--test/harness.h55
-rw-r--r--test/mock_scrape.c210
-rw-r--r--test/mock_scrape.h30
-rwxr-xr-xtest/run_tests.sh21
-rw-r--r--test/stub.h25
8 files changed, 705 insertions, 0 deletions
diff --git a/test/Makefile b/test/Makefile
new file mode 100644
index 0000000..fdc89e7
--- /dev/null
+++ b/test/Makefile
@@ -0,0 +1,47 @@
+# Copyright 2018 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+COLLECTOR_TESTS := cpu
+
+COLLECTOR_TEST_PROGS := $(foreach c,$(COLLECTOR_TESTS),$(c)_test)
+COLLECTOR_TEST_OBJS := $(foreach p,$(COLLECTOR_TEST_PROGS),$(p).o)
+COLLECTOR_TEST_IMPLS := $(foreach p,$(COLLECTOR_TEST_PROGS),$(p).impl.o)
+
+CFLAGS = -std=c11 -Wall -Wextra -pedantic -Wno-format-truncation -Os
+
+# test execution
+
+run_all: $(COLLECTOR_TEST_PROGS) run_tests.sh
+	@./run_tests.sh $(COLLECTOR_TEST_PROGS)
+
+.PHONY: run_all
+
+$(COLLECTOR_TEST_OBJS): %.o: %.c harness.h mock_scrape.h
+	$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
+
+$(COLLECTOR_TEST_IMPLS): %_test.impl.o: ../%.c stub.h
+	$(CC) $(CFLAGS) $(CPPFLAGS) -include stub.h -c -o $@ $<
+
+$(COLLECTOR_TEST_PROGS): %: %.o %.impl.o harness.o mock_scrape.o util.o
+	$(CC) -o $@ $^ $(LDFLAGS) $(LDLIBS)
+
+util.o: ../util.c
+	$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
+
+# make clean
+
+.PHONY: clean
+clean:
+	$(RM) $(COLLECTOR_TEST_PROGS) $(COLLECTOR_TEST_OBJS) $(COLLECTOR_TEST_IMPLS)
+	$(RM) harness.o mock_scrape.o util.o
diff --git a/test/cpu_test.c b/test/cpu_test.c
new file mode 100644
index 0000000..4698236
--- /dev/null
+++ b/test/cpu_test.c
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "harness.h"
+#include "mock_scrape.h"
+#include "../collector.h"
+
+extern const struct collector cpu_collector;
+void cpu_test_override_tick(void *ctx, long tick);
+
+TEST(cpu_metrics) {
+  test_write_file(
+      env,
+      "proc/stat",
+      "cpu  1222 2444 3666 4888 6110 7332 8554 9776\n"
+      "cpu0 1111 2222 3333 4444 5555 6666 7777 8888\n"
+      "cpu1 111 222 333 444 555 666 777 888\n");
+  scrape_req *req = mock_scrape_start(env);
+
+  void *ctx = cpu_collector.init(0, 0);
+  cpu_test_override_tick(ctx, 100);
+  cpu_collector.collect(req, ctx);
+
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "0"}, {"mode", "user"}), 11.11);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "0"}, {"mode", "nice"}), 22.22);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "0"}, {"mode", "system"}), 33.33);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "0"}, {"mode", "idle"}), 44.44);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "0"}, {"mode", "iowait"}), 55.55);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "0"}, {"mode", "irq"}), 66.66);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "0"}, {"mode", "softirq"}), 77.77);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "0"}, {"mode", "steal"}), 88.88);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "1"}, {"mode", "user"}), 1.11);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "1"}, {"mode", "nice"}), 2.22);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "1"}, {"mode", "system"}), 3.33);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "1"}, {"mode", "idle"}), 4.44);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "1"}, {"mode", "iowait"}), 5.55);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "1"}, {"mode", "irq"}), 6.66);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "1"}, {"mode", "softirq"}), 7.77);
+  mock_scrape_expect(req, "node_cpu_seconds_total", LABEL_LIST({"cpu", "1"}, {"mode", "steal"}), 8.88);
+  mock_scrape_expect_no_more(req);
+  mock_scrape_free(req);
+}
+
+TEST(cpufreq_metrics) {
+  test_write_file(env, "sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq", "1234567\n");
+  test_write_file(env, "sys/devices/system/cpu/cpu1/cpufreq/scaling_cur_freq", "987654\n");
+  scrape_req *req = mock_scrape_start(env);
+
+  void *ctx = cpu_collector.init(0, 0);
+  cpu_collector.collect(req, ctx);
+
+  mock_scrape_expect(req, "node_cpu_frequency_hertz", LABEL_LIST({"cpu", "0"}), 1234567000.0);
+  mock_scrape_expect(req, "node_cpu_frequency_hertz", LABEL_LIST({"cpu", "1"}), 987654000.0);
+  mock_scrape_expect_no_more(req);
+  mock_scrape_free(req);
+}
+
+TEST_SUITE {
+  TEST_SUITE_START;
+  RUN_TEST(cpu_metrics);
+  RUN_TEST(cpufreq_metrics);
+  TEST_SUITE_END;
+}
diff --git a/test/harness.c b/test/harness.c
new file mode 100644
index 0000000..d88a092
--- /dev/null
+++ b/test/harness.c
@@ -0,0 +1,241 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#define _POSIX_C_SOURCE 200809L
+
+#include <dirent.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <setjmp.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#include "harness.h"
+#include "../util.h"
+
+struct test_env {
+  const char *current_testcase;
+  jmp_buf escape_env;
+
+  int tmpdir_fd;
+  const char *tmpdir_name;
+
+  int testdir_fd;
+  char *testdir_name;
+};
+
+bool test_main(test_env *env);
+
+static void testdir_setup(test_env *env);
+static int testdir_getdir(test_env *env, const char *path);
+static void testdir_closedir(test_env *env, int dir_fd);
+static bool testdir_rmtree(int parent_fd, int dir_fd, const char *dir_name);
+
+void test_write_file(test_env *env, const char *path, const char *contents) {
+  if (*path == '/')
+    test_fail(env, "test_write_file path is absolute: %s", path);
+  const char *file = strrchr(path, '/');
+  file = file ? file + 1 : path;
+
+  testdir_setup(env);
+  int dir_fd = testdir_getdir(env, path);
+  int fd = openat(dir_fd, file, O_WRONLY | O_CREAT | O_TRUNC, 0600);
+  if (fd == -1)
+    test_fail(env, "in %s: %s: unable to open for writing: %s", path, file, strerror(errno));
+  testdir_closedir(env, dir_fd);
+
+  if (write_all(fd, contents, strlen(contents)) == -1)
+    test_fail(env, "in %s: %s: failed write: %s", path, file, strerror(errno));
+
+  close(fd);
+}
+
+void test_fail(test_env *env, const char *err, ...) {
+  va_list ap;
+
+  printf("FAILED: ");
+  va_start(ap, err);
+  vprintf(err, ap);
+  va_end(ap);
+  printf("\n");
+
+  longjmp(env->escape_env, 1);
+}
+
+bool test_start(test_env *env, const char *name) {
+  printf("test: %s\n", name);
+  env->current_testcase = name;
+
+  env->testdir_fd = -1;
+  env->testdir_name = 0;
+
+  return true;
+}
+
+jmp_buf *test_escape(test_env *env) {
+  return &env->escape_env;
+}
+
+bool test_cleanup(test_env *env) {
+  int success = true;
+
+  if (env->testdir_name) {
+    success = success && testdir_rmtree(env->tmpdir_fd, env->testdir_fd, env->testdir_name);
+    free(env->testdir_name);
+  }
+
+  if (!success)
+    printf("FAILED: test cleanup failed\n");
+
+  env->current_testcase = 0;
+  return success;
+}
+
+int main(void) {
+  test_env env;
+  env.current_testcase = 0;
+  env.tmpdir_fd = -1;
+
+  bool success = test_main(&env);
+
+  if (env.tmpdir_fd != -1)
+    close(env.tmpdir_fd);
+
+  return success ? EXIT_SUCCESS : EXIT_FAILURE;
+}
+
+static void testdir_setup(test_env *env) {
+  char buf[64];
+
+  if (env->testdir_name)
+    return;  // already done
+
+  if (env->tmpdir_fd == -1) {
+    env->tmpdir_name = getenv("TMPDIR");
+    if (!env->tmpdir_name)
+      env->tmpdir_name = "/tmp";
+    env->tmpdir_fd = open(env->tmpdir_name, O_RDONLY | O_DIRECTORY);
+    if (env->tmpdir_fd == -1)
+      test_fail(env, "failed to open temporary file directory: %s: %s", env->tmpdir_name, strerror(errno));
+  }
+  
+  for (unsigned attempt = 0; attempt < 100; attempt++) {
+    unsigned base = 0;
+    for (const char *p = env->current_testcase; p && *p; p++)
+      base = (base << 5) + base + (unsigned char) *p;
+    snprintf(buf, sizeof buf, "nano_exporter_test_%x", base + attempt);
+    if (mkdirat(env->tmpdir_fd, buf, 0700) == 0) {
+      env->testdir_fd = openat(env->tmpdir_fd, buf, O_RDONLY | O_DIRECTORY);
+      if (env->testdir_fd == -1)
+        test_fail(env, "failed to open test work directory in %s: %s: %s", env->tmpdir_name, buf, strerror(errno));
+      env->testdir_name = must_strdup(buf);
+      if (fchdir(env->testdir_fd) == -1)
+        test_fail(env, "unable to change to test work directory in %s: %s: %s", env->tmpdir_name, buf, strerror(errno));
+      return;
+    }
+  }
+
+  test_fail(env, "failed to create test work directory in %s: %s: %s", env->tmpdir_name, buf, strerror(errno));
+}
+
+static int testdir_getdir(test_env *env, const char *orig_path) {
+  char buf[NAME_MAX + 1];
+  struct stat st;
+
+  int parent_fd = env->testdir_fd;
+
+  const char *path = orig_path;
+  char *slash;
+  while ((slash = strchr(path, '/'))) {
+    if (slash - path > (ptrdiff_t) sizeof buf - 1)
+      test_fail(env, "path name component too long: %s", path);
+    snprintf(buf, sizeof buf, "%.*s", (int)(slash - path), path);
+
+    if (fstatat(parent_fd, buf, &st, AT_SYMLINK_NOFOLLOW) == 0) {
+      if (!S_ISDIR(st.st_mode))
+        test_fail(env, "in %s: %s: exists and not a directory", orig_path, buf);
+      // already a directory, just try to open it
+    } else if (errno != ENOENT) {
+      test_fail(env, "in %s: %s: failed to stat: %s", orig_path, buf, strerror(errno));
+    } else {
+      // doesn't exist: make a new directory
+      if (mkdirat(parent_fd, buf, 0700) == -1)
+        test_fail(env, "in %s: %s: failed to mkdir: %s", orig_path, buf, strerror(errno));
+    }
+
+    int fd = openat(parent_fd, buf, O_RDONLY | O_DIRECTORY);
+    if (fd == -1)
+      test_fail(env, "in %s: %s: failed to open: %s", orig_path, buf, strerror(errno));
+
+    if (parent_fd != env->testdir_fd)
+      close(parent_fd);
+    parent_fd = fd;
+
+    path = slash + 1;
+  }
+
+  return parent_fd;
+}
+
+static void testdir_closedir(test_env *env, int dir_fd) {
+  if (dir_fd != env->testdir_fd)
+    close(dir_fd);
+}
+
+static bool testdir_rmtree(int parent_fd, int dir_fd, const char *dir_name) {
+  DIR *d = fdopendir(dir_fd);
+  if (!d) {
+    close(dir_fd);
+    return false;
+  }
+
+  bool success = true;
+
+  struct dirent *dent;
+  while ((dent = readdir(d))) {
+    if (strcmp(dent->d_name, ".") == 0 || strcmp(dent->d_name, "..") == 0)
+      continue;
+
+    struct stat st;
+    if (fstatat(dir_fd, dent->d_name, &st, AT_SYMLINK_NOFOLLOW) == -1) {
+      success = false;
+      continue;
+    }
+
+    if (S_ISREG(st.st_mode) || S_ISLNK(st.st_mode)) {
+      if (unlinkat(dir_fd, dent->d_name, 0) == -1)
+        success = false;
+    } else if (S_ISDIR(st.st_mode)) {
+      int fd = openat(dir_fd, dent->d_name, O_RDONLY | O_DIRECTORY);
+      if (fd == -1 || !testdir_rmtree(dir_fd, fd, dent->d_name))
+        success = false;
+    } else {
+      printf("unexpected item in bagging area: %s: not file, link or directory\n", dent->d_name);
+      success = false;
+    }
+  }
+
+  closedir(d);
+  if (success)
+    success = unlinkat(parent_fd, dir_name, AT_REMOVEDIR) == 0;
+
+  return success;
+}
diff --git a/test/harness.h b/test/harness.h
new file mode 100644
index 0000000..e7013a7
--- /dev/null
+++ b/test/harness.h
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef NANO_EXPORTER_TEST_HARNESS_H_
+#define NANO_EXPORTER_TEST_HARNESS_H_ 1
+
+#include <setjmp.h>
+#include <stdbool.h>
+#include <stdlib.h>
+
+typedef struct test_env test_env;
+
+// functions for use from tests
+
+void test_write_file(test_env *env, const char *path, const char *contents);
+
+void test_fail(test_env *env, const char *err, ...);
+
+// macros for defining test cases
+
+#define TEST(name) void testcase_##name(test_env *env)
+
+#define TEST_SUITE bool test_main(test_env *env)
+
+#define TEST_SUITE_START volatile bool success = true
+
+#define RUN_TEST(name) do { \
+    if (!test_start(env, #name)) return false; \
+    if (setjmp(*test_escape(env)) != 0) { success = false; break; } \
+    testcase_##name(env); \
+    if (!test_cleanup(env)) return false; \
+  } while (0)
+
+#define TEST_SUITE_END return success
+
+// internal functions used by the macros
+
+bool test_start(test_env *env, const char *name);
+jmp_buf *test_escape(test_env *env);
+bool test_cleanup(test_env *env);
+
+#endif // NANO_EXPORTER_TEST_HARNESS_H_
diff --git a/test/mock_scrape.c b/test/mock_scrape.c
new file mode 100644
index 0000000..82a17d7
--- /dev/null
+++ b/test/mock_scrape.c
@@ -0,0 +1,210 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <math.h>
+#include <string.h>
+
+#include "harness.h"
+#include "../scrape.h"
+#include "../util.h"
+
+#define MAX_METRICS 128
+#define MAX_RAWS 16
+
+struct scrape_metric {
+  char *metric;
+  struct label *labels;
+  double value;
+};
+
+struct scrape_req {
+  test_env *env;
+
+  struct scrape_metric metrics[MAX_METRICS];
+  unsigned metrics_written;
+  unsigned metrics_tested;
+
+  const char *raws[MAX_RAWS];
+  unsigned raws_written;
+  unsigned raws_tested;
+};
+
+static void dump_metrics(scrape_req *req);
+static void dump_metric(const char *metric, const struct label *labels, double value);
+
+static struct label *copy_labels(const struct label *labels);
+static void free_labels(struct label *labels);
+static void compare_labels(scrape_req *req, const struct label *got, const struct label *expected);
+
+void scrape_write(scrape_req *req, const char *metric, const struct label *labels, double value) {
+  if (req->metrics_written >= MAX_METRICS)
+    test_fail(req->env, "exceeded MAX_METRICS: %u metrics already written", req->metrics_written);
+
+  struct scrape_metric *rec = &req->metrics[req->metrics_written++];
+  rec->metric = must_strdup(metric);
+  rec->labels = copy_labels(labels);
+  rec->value = value;
+}
+
+void scrape_write_raw(scrape_req *req, const void *buf, size_t len) {
+  if (req->metrics_written >= MAX_RAWS)
+    test_fail(req->env, "exceeded MAX_RAWS: %u raw blocks already written", req->raws_written);
+
+  test_fail(req->env, "TODO: implement scrape_write_raw");
+}
+
+scrape_req *mock_scrape_start(test_env *env) {
+  scrape_req *req = must_malloc(sizeof *req);
+  req->env = env;
+  req->metrics_written = req->metrics_tested = 0;
+  req->raws_written = req->raws_tested = 0;
+  return req;
+}
+
+void mock_scrape_free(scrape_req *req) {
+  for (unsigned i = 0; i < req->metrics_written; i++) {
+    free(req->metrics[i].metric);
+    free_labels(req->metrics[i].labels);
+  }
+  free(req);
+}
+
+void mock_scrape_expect(scrape_req *req, const char *metric, const struct label *labels, double value) {
+  if (req->metrics_tested >= req->metrics_written) {
+    dump_metrics(req);
+    test_fail(req->env, "got no more metrics, expected %s", metric);
+  }
+  struct scrape_metric *got = &req->metrics[req->metrics_tested];
+
+  if (strcmp(got->metric, metric) != 0) {
+    dump_metrics(req);
+    test_fail(req->env, "got metric %s, expected %s", got->metric, metric);
+  }
+  compare_labels(req, got->labels, labels);
+  if (fabs(got->value - value) >= 1e-6) {
+    dump_metrics(req);
+    test_fail(req->env, "got metric value %.16g, expected %.16g", got->value, value);
+  }
+
+  req->metrics_tested++;
+}
+
+void mock_scrape_expect_raw(scrape_req *req, const char *str) {
+  test_fail(req->env, "TODO: implement");
+}
+
+void mock_scrape_expect_no_more(scrape_req *req) {
+  if (req->metrics_tested < req->metrics_written) {
+    dump_metrics(req);
+    test_fail(req->env, "got metric %s, expected no more metrics", req->metrics[req->metrics_tested].metric);
+  }
+  if (req->raws_tested < req->raws_written) {
+    test_fail(req->env, "TODO: raw stuff");
+  }
+}
+
+static void dump_metrics(scrape_req *req) {
+  if (req->metrics_tested > 0) {
+    printf("expected metrics:\n");
+    for (unsigned i = 0; i < req->metrics_tested; i++) {
+      struct scrape_metric *m = &req->metrics[i];
+      dump_metric(m->metric, m->labels, m->value);
+    }
+  }
+
+  if (req->metrics_written > req->metrics_tested) {
+    printf("unexpected metrics:\n");
+    for (unsigned i = req->metrics_tested; i < req->metrics_written; i++) {
+      struct scrape_metric *m = &req->metrics[i];
+      dump_metric(m->metric, m->labels, m->value);
+    }
+  }
+
+  // TODO: dump raw
+}
+
+static void dump_metric(const char *metric, const struct label *labels, double value) {
+  fputs("  ", stdout);
+  fputs(metric, stdout);
+  if (labels && labels->key) {
+    fputc('{', stdout);
+    for (const struct label *l = labels; l->key; l++) {
+      if (l != labels)
+        fputc(',', stdout);
+      printf("%s=\"%s\"", l->key, l->value);
+    }
+    fputc('}', stdout);
+  }
+  printf(" %.16g\n", value);
+}
+
+
+static struct label *copy_labels(const struct label *labels) {
+  if (!labels)
+    return 0;
+
+  size_t count = 0;
+  for (const struct label *p = labels; p->key; p++)
+    count++;
+  if (!count)
+    return 0;
+
+  struct label *copy = must_malloc((count + 1) * sizeof *copy);
+  for (size_t i = 0; i < count; i++) {
+    copy[i].key = must_strdup(labels[i].key);
+    copy[i].value = must_strdup(labels[i].value);
+  }
+  copy[count] = LABEL_END;
+
+  return copy;
+}
+
+static void free_labels(struct label *labels) {
+  if (!labels)
+    return;
+  for (struct label *p = labels; p->key; p++) {
+    free(p->key);
+    free(p->value);
+  }
+  free(labels);
+}
+
+static void compare_labels(scrape_req *req, const struct label *got, const struct label *expected) {
+  if (!got || !got->key) {
+    if (!expected || !expected->key)
+      return;
+    dump_metrics(req);
+    test_fail(req->env, "got no more labels, expected %s=\"%s\"", expected->key, expected->value);
+  }
+
+  while (got->key && expected->key) {
+    if (strcmp(got->key, expected->key) != 0 || strcmp(got->value, expected->value) != 0) {
+      dump_metrics(req);
+      test_fail(req->env, "got label %s=\"%s\", expected %s=\"%s\"", got->key, got->value, expected->key, expected->value);
+    }
+    got++;
+    expected++;
+  }
+
+  if (got->key) {
+    dump_metrics(req);
+    test_fail(req->env, "got label %s=\"%s\", expected no more labels", got->key, got->value);
+  }
+  if (expected->key) {
+    dump_metrics(req);
+    test_fail(req->env, "got no more labels, expected %s=\"%s\"", expected->key, expected->value);
+  }
+}
diff --git a/test/mock_scrape.h b/test/mock_scrape.h
new file mode 100644
index 0000000..bbb3767
--- /dev/null
+++ b/test/mock_scrape.h
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef NANO_EXPORTER_TEST_MOCK_SCRAPE_H_
+#define NANO_EXPORTER_TEST_MOCK_SCRAPE_H_ 1
+
+#include "harness.h"
+#include "../scrape.h"
+
+scrape_req *mock_scrape_start(test_env *env);
+void mock_scrape_free(scrape_req *req);
+
+void mock_scrape_expect(scrape_req *req, const char *metric, const struct label *labels, double value);
+void mock_scrape_expect_raw(scrape_req *req, const char *str);
+void mock_scrape_expect_no_more(scrape_req *req);
+
+#endif // NANO_EXPORTER_TEST_MOCK_SCRAPE_H_
diff --git a/test/run_tests.sh b/test/run_tests.sh
new file mode 100755
index 0000000..12a0e1a
--- /dev/null
+++ b/test/run_tests.sh
@@ -0,0 +1,21 @@
+#! /bin/bash
+
+failed=0
+
+for suite in "$@"; do
+    echo "$suite:"
+    if ./$suite; then
+        echo "...pass"
+    else
+        echo "...FAIL"
+        failed=1
+    fi
+done
+
+if (($failed)); then
+    printf "\x1b[31;1mTESTS FAILED\x1b[0m\n"
+    exit 1
+fi
+
+printf "\x1b[32;1mall tests passed\x1b[0m\n"
+exit 0
diff --git a/test/stub.h b/test/stub.h
new file mode 100644
index 0000000..7aee0b3
--- /dev/null
+++ b/test/stub.h
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef NANO_EXPORTER_TEST_STUB_H_
+#define NANO_EXPORTER_TEST_STUB_H_ 1
+
+// This file is prepended in front of collector implementations (via
+// -include stub.h) when compiling them for tests.
+
+#define PATH(p) ("." p)
+
+#endif // NANO_EXPORTER_TEST_STUB_H_