Repository: Kinto/kinto

Issue:
Port loadtest to use Molotov



Discussion:

Resolving patch:
diff --git a/loadtests/loadtest/__init__.py b/loadtests/loadtest/__init__.py
deleted file mode 100644
--- a/loadtests/loadtest/__init__.py
+++ /dev/null
@@ -1,92 +0,0 @@
-import json
-import os
-import uuid
-
-from requests.auth import HTTPBasicAuth, AuthBase
-from loads.case import TestCase
-from konfig import Config
-
-
-class RawAuth(AuthBase):
-    def __init__(self, authorization):
-        self.authorization = authorization
-
-    def __call__(self, r):
-        r.headers['Authorization'] = self.authorization
-        return r
-
-
-class BaseLoadTest(TestCase):
-    def __init__(self, *args, **kwargs):
-        """Initialization that happens once per user.
-
-        :note:
-
-            This method is called as many times as number of users.
-        """
-        super().__init__(*args, **kwargs)
-
-        self.conf = self._get_configuration()
-
-        if self.conf.get('smoke', False):
-            self.random_user = "test@restmail.net"
-            self.auth = RawAuth("Bearer %s" % self.conf.get('token'))
-        else:
-            self.random_user = uuid.uuid4().hex
-            self.auth = HTTPBasicAuth(self.random_user, 'secret')
-
-        self.session.auth = self.auth
-        self.session.headers.update({'Content-Type': 'application/json'})
-
-        self.bucket = 'default'
-        self.collection = 'default'
-
-        # Keep track of created objects.
-        self._collections_created = {}
-
-    def _get_configuration(self):
-        # Loads is removing the extra information contained in the ini files,
-        # so we need to parse it again.
-        config_file = self.config['config']
-        # When copying the configuration files, we lose the config/ prefix so,
-        # try to read from this folder in case the file doesn't exist.
-        if not os.path.isfile(config_file):
-            config_file = os.path.basename(config_file)
-            if not os.path.isfile(config_file):
-                msg = 'Unable to locate the configuration file, aborting.'
-                raise LookupError(msg)
-        return Config(config_file).get_map('loads')
-
-    def api_url(self, path):
-        url = "{0}/v1/{1}".format(self.server_url.rstrip('/'), path)
-        return url
-
-    def bucket_url(self, bucket=None, prefix=True):
-        url = 'buckets/%s' % (bucket or self.bucket)
-        return self.api_url(url) if prefix else '/' + url
-
-    def group_url(self, bucket=None, group=None, prefix=True):
-        bucket_url = self.bucket_url(bucket, prefix)
-        group = group or self.group
-        return '%s/groups/%s' % (bucket_url, group)
-
-    def collection_url(self, bucket=None, collection=None, prefix=True):
-        bucket_url = self.bucket_url(bucket, prefix)
-        collection = collection or self.collection
-        collection_url = bucket_url + '/collections/%s' % collection
-
-        # Create collection objects.
-        if collection not in self._collections_created:
-            self.session.put(bucket_url,
-                             data=json.dumps({'data': {}}),
-                             headers={'If-None-Match': '*'})
-            self.session.put(collection_url,
-                             data=json.dumps({'data': {}}),
-                             headers={'If-None-Match': '*'})
-            self._collections_created[collection] = True
-
-        return collection_url + '/records'
-
-    def record_url(self, record_id, bucket=None, collection=None, prefix=True):
-        collection_url = self.collection_url(bucket, collection, prefix)
-        return collection_url + '/%s' % record_id
diff --git a/loadtests/loadtest/schemas.py b/loadtests/loadtest/schemas.py
deleted file mode 100644
--- a/loadtests/loadtest/schemas.py
+++ /dev/null
@@ -1,110 +0,0 @@
-import json
-import os
-import random
-import uuid
-
-from . import BaseLoadTest
-
-
-COLLECTIONS = ('addons', 'certificates', 'gfx', 'plugins')
-ACTIONS = ('create_put',)
-
-
-class SchemaValidationTest(BaseLoadTest):
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self._init_collections()
-
-    def _init_collections(self):
-        # Create `blocklist` bucket
-        bucket = {
-            'permissions': {'write': ['system.Authenticated']}
-        }
-        self.session.put(self.bucket_url('blocklist'),
-                         data=json.dumps(bucket),
-                         headers={'If-None-Match': '*'})
-        for collection in COLLECTIONS:
-            self._create_collection(collection)
-
-    def _create_collection(self, name):
-        here = os.path.abspath(os.path.dirname(__file__))
-        schemafile = os.path.join(here, '..', 'schemas', name + '.json')
-        jsonschema = json.load(open(schemafile))
-        collection = {
-            'data': {'schema': jsonschema}
-        }
-        self.session.put(self.bucket_url('blocklist') + '/collections/' + name,
-                         data=json.dumps(collection),
-                         headers={'If-None-Match': '*'})
-
-    def _build_record(self, collection):
-        by_collection = {
-            'addons': {
-                'addonId': 'abc',
-                'prefs': [],
-                'versionRange': []
-            },
-            'certificates': {
-                'issuerName': '',
-                'serialNumber': ''
-            },
-            'gfx': {
-                'os': '',
-                'vendor': '',
-                'devices': [],
-                'feature': '',
-                'featureStatus': '',
-                'driverVersion': '',
-                'driverVersionComparator': ''
-            },
-            'plugins': {
-                'versionRange': [{
-                    'minVersion': '',
-                    'maxVersion': '',
-                    'severity': '0',
-                    'vulnerabilityStatus': '',
-                    'targetApplication': {
-                        'id': '',
-                        'minVersion': '',
-                        'maxVersion': ''
-                    }
-                }]
-            },
-        }
-        return by_collection[collection]
-
-    def validate_records(self):
-        """
-        Main entry.
-        """
-        collection = os.getenv('VALIDATE_COLLECTION')
-        if collection is None:
-            collection = random.choice(COLLECTIONS)
-        self.incr_counter('collection-%s' % collection)
-
-        action = os.getenv('LOAD_ACTION')
-        if action is None:
-            action = random.choice(ACTIONS)
-        self.incr_counter('action-%s' % action)
-
-        return getattr(self, action)(collection)
-
-    def create_put(self, collection):
-        record_id = uuid.uuid4()
-        record_url = self.record_url(record_id,
-                                     bucket='blocklist',
-                                     collection=collection)
-        # Check that invalid record fails
-        resp = self.session.put(record_url,
-                                data=json.dumps({'data': {}}),
-                                headers={'If-None-Match': '*'})
-        self.incr_counter("status-%s" % resp.status_code)
-        self.assertEqual(resp.status_code, 400)
-
-        # Create valid record succeeds
-        record = self._build_record(collection)
-        resp = self.session.put(record_url,
-                                data=json.dumps({'data': record}),
-                                headers={'If-None-Match': '*'})
-        self.incr_counter("status-%s" % resp.status_code)
-        self.assertEqual(resp.status_code, 201)
diff --git a/loadtests/loadtest/simulation.py b/loadtests/loadtest/simulation.py
deleted file mode 100644
--- a/loadtests/loadtest/simulation.py
+++ /dev/null
@@ -1,330 +0,0 @@
-import json
-import os
-import random
-import uuid
-
-from . import BaseLoadTest
-
-ACTIONS_FREQUENCIES = [
-    ('create', 20),
-    ('create_put', 20),
-    ('batch_create', 50),
-    ('batch_create_put', 50),
-    ('batch_replace', 50),
-    ('batch_update', 50),
-    ('update', 50),
-    ('filter_sort', 60),
-    ('delete', 10),
-    ('batch_delete', 10),
-    ('poll_changes', 90),
-    ('list_archived', 20),
-    ('list_deleted', 40),
-    ('batch_count', 50),
-    ('list_continuated_pagination', 80),
-]
-
-
-def build_article():
-    suffix = uuid.uuid4().hex
-    data = {
-        "title": "Corp Site {0}".format(suffix),
-        "url": "http://mozilla.org/{0}".format(suffix),
-        "resolved_url": "http://mozilla.org/{0}".format(suffix),
-        "added_by": "FxOS-{0}".format(suffix),
-    }
-    return data
-
-
-class SimulationLoadTest(BaseLoadTest):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.collection = 'articles'
-        self.init_user()
-
-    def init_user(self, *args, **kwargs):
-        """Initialization that happens once per user.
-
-        :note:
-
-            This method is called as many times as number of users.
-        """
-        # Create at least some records for this user
-        max_initial_records = self.conf.get('max_initial_records', 100)
-        self.nb_initial_records = random.randint(3, max_initial_records)
-        self.batch_requests_size = self.conf.get('batch_requests_size', 25)
-
-        self.bucket = os.getenv('BUCKET', self.conf.get('bucket', 'default'))
-        if self.bucket != 'default':
-            # Create bucket + collection:
-            self.collection_url()
-
-    def _pickRecords(self):
-        resp = self.session.get(self.collection_url())
-        self.records = resp.json()['data']
-
-        # Create some records, if not any in collection.
-        if len(self.records) < self.nb_initial_records:
-            for i in range(self.nb_initial_records):
-                self.create()
-            resp = self.session.get(self.collection_url())
-            self.records = resp.json()['data']
-
-        # Pick a random record
-        self.random_record = random.choice(self.records)
-        self.random_id = self.random_record['id']
-        self.random_url = self.record_url(self.random_id)
-
-        # Pick another random, different
-        self.records.remove(self.random_record)
-        self.random_record_2 = random.choice(self.records)
-        self.random_id_2 = self.random_record_2['id']
-        self.random_url_2 = self.record_url(self.random_id_2)
-
-    def setUp(self):
-        """Choose some random records in the whole collection.
-
-        :note:
-
-            This method is called as many times as number of hits.
-        """
-        pass
-
-    def test_simulation(self):
-        """
-        :note:
-
-            This method is called as many times as number of hits.
-        """
-        preset = os.getenv('LOAD_PRESET', self.conf.get('preset', 'random'))
-
-        rand = random.randint(0, 100)
-        if preset == 'random':
-            # Choose a random action among available, if not frequent enough,
-            # try again recursively.
-            action, percentage = random.choice(ACTIONS_FREQUENCIES)
-            if rand < percentage:
-                self.incr_counter(action)
-                return getattr(self, action)()
-            else:
-                self.test_simulation()
-
-        elif preset == "exhaustive":
-            # Make sure we exhaustive every action.
-            actions = [a for (a, _) in ACTIONS_FREQUENCIES]
-            for action in actions:
-                getattr(self, action)()
-
-        elif preset == "read":
-            if rand < 2:
-                action = 'batch_create_put'
-            elif rand < 80:
-                action = 'poll_changes'
-            elif rand < 90:
-                action = 'list_deleted'
-            else:
-                action = 'filter_sort'
-            self.incr_counter(action)
-            getattr(self, action)()
-
-        elif preset == "write":
-            if rand < 20:
-                action = 'batch_create'
-            elif rand < 40:
-                action = 'batch_create_put'
-            elif rand < 60:
-                action = 'batch_replace'
-            elif rand < 90:
-                action = 'batch_delete'
-            else:
-                action = 'poll_changes'
-            self.incr_counter(action)
-            getattr(self, action)()
-
-    def create(self):
-        article = build_article()
-        resp = self.session.post(
-            self.collection_url(),
-            data=json.dumps({'data': article}),
-            headers={'Content-Type': 'application/json'})
-        self.incr_counter('status-%s' % resp.status_code)
-        self.assertEqual(resp.status_code, 201)
-
-    def create_put(self):
-        article = build_article()
-        resp = self.session.put(
-            self.record_url(uuid.uuid4()),
-            data=json.dumps({'data': article}),
-            headers={'Content-Type': 'application/json'})
-        self.incr_counter('status-%s' % resp.status_code)
-        self.assertEqual(resp.status_code, 201)
-
-    def batch_create(self):
-        data = {
-            "defaults": {
-                "method": "POST",
-                "path": self.collection_url(prefix=False)
-            }
-        }
-        for i in range(self.batch_requests_size):
-            request = {"body": {"data": build_article()}}
-            data.setdefault("requests", []).append(request)
-
-        self._run_batch(data)
-
-    def batch_create_put(self):
-        data = {
-            "defaults": {
-                "method": "PUT",
-            }
-        }
-        for i in range(self.batch_requests_size):
-            path = self.record_url(uuid.uuid4(), prefix=False)
-            request = {"path": path, "body": {"data": build_article()}}
-            data.setdefault("requests", []).append(request)
-
-        self._run_batch(data)
-
-    def filter_sort(self):
-        queries = [
-            [('archived', 'false')],
-            [('unread', 'true'), ('archived', 'false')],
-            [('_sort', '-last_modified'), ('archived', 'true')],
-            [('_sort', 'title')],
-            [('_sort', '-added_by,-stored_on'), ('archived', 'false')],
-        ]
-        queryparams = random.choice(queries)
-        query_url = '&'.join(['='.join(param) for param in queryparams])
-        url = self.collection_url() + '?' + query_url
-        resp = self.session.get(url)
-        self.incr_counter('status-%s' % resp.status_code)
-        self.assertEqual(resp.status_code, 200)
-
-    def _patch(self, url, data, status=200):
-        data = json.dumps(data)
-        resp = self.session.patch(url, data)
-        self.incr_counter('status-%s' % resp.status_code)
-        self.assertEqual(resp.status_code, status)
-
-    def _run_batch(self, data):
-        resp = self.session.post(self.api_url('batch'),
-                                 data=json.dumps(data))
-        self.incr_counter('status-%s' % resp.status_code)
-        self.assertEqual(resp.status_code, 200)
-        for subresponse in resp.json()['responses']:
-            self.incr_counter('status-%s' % subresponse['status'])
-
-    def update(self):
-        self._pickRecords()
-        data = {
-            "title": "Some title {}".format(random.randint(0, 1)),
-            "archived": bool(random.randint(0, 1)),
-            "is_article": bool(random.randint(0, 1)),
-            "favorite": bool(random.randint(0, 1)),
-        }
-        self._patch(self.random_url, {"data": data})
-
-    def batch_replace(self):
-        self._pickRecords()
-        data = {
-            "defaults": {
-                "method": "PUT"
-            }
-        }
-        nb_batched = min(self.nb_initial_records, len(self.records))
-        records = random.sample(self.records, nb_batched)
-        for record in records:
-            record_url = self.record_url(record['id'], prefix=False)
-            request = {"path": record_url, "body": {"data": build_article()}}
-            data.setdefault("requests", []).append(request)
-
-        self._run_batch(data)
-
-    def batch_update(self):
-        self._pickRecords()
-        data = {
-            "defaults": {
-                "method": "PATCH"
-            }
-        }
-        nb_batched = min(self.nb_initial_records, len(self.records))
-        records = random.sample(self.records, nb_batched)
-        for record in records:
-            record_url = self.record_url(record['id'], prefix=False)
-            change = {"title": "Some title %s" % random.randint(0, 100)}
-            request = {"path": record_url, "body": {"data": change}}
-            data.setdefault("requests", []).append(request)
-
-        self._run_batch(data)
-
-    def delete(self):
-        self._pickRecords()
-        resp = self.session.delete(self.random_url)
-        self.incr_counter('status-%s' % resp.status_code)
-        self.assertEqual(resp.status_code, 200)
-
-    def batch_delete(self):
-        self._pickRecords()
-        # Get some random articles on which the batch will be applied
-        nb_deleted = min(self.nb_initial_records, len(self.records))
-        records = random.sample(self.records, nb_deleted)
-        data = {
-            "defaults": {
-                "method": "DELETE"
-            }
-        }
-        for record in records[:self.batch_requests_size]:
-            request = {"path": self.record_url(record['id'], prefix=False)}
-            data.setdefault("requests", []).append(request)
-
-        self._run_batch(data)
-
-    def poll_changes(self):
-        self._pickRecords()
-        last_modified = self.random_record['last_modified']
-        filters = '?_since=%s' % last_modified
-        modified_url = self.collection_url() + filters
-        resp = self.session.get(modified_url)
-        self.incr_counter('status-%s' % resp.status_code)
-        self.assertEqual(resp.status_code, 200)
-
-    def list_archived(self):
-        archived_url = self.collection_url() + '?archived=true'
-        resp = self.session.get(archived_url)
-        self.incr_counter('status-%s' % resp.status_code)
-        self.assertEqual(resp.status_code, 200)
-
-    def batch_count(self):
-        base_url = self.collection_url(prefix=False)
-        data = {
-            "defaults": {
-                "method": "HEAD",
-            },
-            "requests": [
-                {"path": base_url + "?archived=true"},
-                {"path": base_url + "?is_article=true"},
-                {"path": base_url + "?favorite=true"},
-                {"path": base_url + "?unread=false"},
-                {"path": base_url + "?min_read_position=100"}
-            ]
-        }
-        self._run_batch(data)
-
-    def list_deleted(self):
-        self._pickRecords()
-        modif = self.random_record['last_modified']
-        filters = '?_since=%s&deleted=true' % modif
-        deleted_url = self.collection_url() + filters
-        resp = self.session.get(deleted_url)
-        self.incr_counter('status-%s' % resp.status_code)
-        self.assertEqual(resp.status_code, 200)
-
-    def list_continuated_pagination(self):
-        paginated_url = self.collection_url() + '?_limit=20'
-        while paginated_url:
-            resp = self.session.get(paginated_url)
-            self.incr_counter('status-%s' % resp.status_code)
-            self.assertEqual(resp.status_code, 200)
-            next_page = resp.headers.get("Next-Page")
-            self.assertNotEqual(paginated_url, next_page)
-            paginated_url = next_page


Test patch:
diff --git a/loadtests/config/test.ini b/loadtests/config/test.ini
deleted file mode 100644
--- a/loadtests/config/test.ini
+++ /dev/null
@@ -1,9 +0,0 @@
-[loads]
-hits = 30
-users = 1
-max_initial_records = 25
-no-dns-resolve = true
-include_file = loadtest
-
-bucket = blog
-preset = read
diff --git a/tests/functional.ini b/tests/functional.ini
new file mode 100644
--- /dev/null
+++ b/tests/functional.ini
@@ -0,0 +1,68 @@
+[server:main]
+use = egg:waitress#main
+host = 0.0.0.0
+port = %(http_port)s
+
+
+[app:main]
+use = egg:kinto
+
+#
+# Backends.
+#
+# https://kinto.readthedocs.io/en/latest/configuration/settings.html#storage
+#
+kinto.storage_backend = kinto.core.storage.memory
+kinto.storage_url =
+kinto.cache_backend = kinto.core.cache.memory
+kinto.cache_url =
+# kinto.cache_max_size_bytes = 524288
+kinto.permission_backend = kinto.core.permission.memory
+kinto.permission_url =
+kinto.flush_endpoint_enabled = true
+
+#
+# Auth configuration.
+#
+# https://kinto.readthedocs.io/en/latest/configuration/settings.html#authentication
+#
+kinto.userid_hmac_secret = b6626ff6d28b02367127d80e2e9b596034af6c49c23eb02995dc8a76049f7b65
+multiauth.policies = basicauth
+
+#
+# Plugins
+#
+kinto.includes = kinto.plugins.default_bucket kinto.plugins.admin
+
+#
+# Logging configuration
+#
+
+# kinto.logging_renderer = kinto.core.logs.ClassicLogRenderer
+
+[loggers]
+keys = root, kinto
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = INFO
+handlers = console
+
+[logger_kinto]
+level = DEBUG
+handlers =
+qualname = kinto
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s
diff --git a/loadtests/loadtest/tutorial.py b/tests/functional.py
similarity index 56%
rename from loadtests/loadtest/tutorial.py
rename to tests/functional.py
--- a/loadtests/loadtest/tutorial.py
+++ b/tests/functional.py
@@ -1,14 +1,22 @@
-import json
+import os.path
+from urllib.parse import urljoin
+
 import random
 import re
+import unittest
 import uuid
 
-from requests.auth import HTTPBasicAuth
-from . import BaseLoadTest
+import requests
+
+
+__HERE__ = os.path.abspath(os.path.dirname(__file__))
+
+SERVER_URL = "http://localhost:8888/v1"
+DEFAULT_AUTH = ('user', 'p4ssw0rd')
 
 
 def build_task():
-    suffix = unicode(uuid.uuid4())
+    suffix = str(uuid.uuid4())
     data = {
         "description": "Task description {0}".format(suffix),
         "status": random.choice(("todo", "doing")),
@@ -16,66 +24,74 @@ def build_task():
     return data
 
 
-class TutorialLoadTest(BaseLoadTest):
-    def test_tutorial(self):
-        self.play_user_default_bucket_tutorial()
-        self.play_user_shared_bucket_tutorial()
-        self.check_for_lists()
+class FunctionalTest(unittest.TestCase):
+
+    def __init__(self, *args, **kwargs):
+        super(FunctionalTest, self).__init__(*args, **kwargs)
+        # XXX Read the configuration from env variables.
+        self.server_url = SERVER_URL
+        self.auth = DEFAULT_AUTH
+        self.session = requests.Session()
+        self.session.auth = DEFAULT_AUTH
+
+    def tearDown(self):
+        # Delete all the created objects
+        flush_url = urljoin(self.server_url, '/__flush__')
+        resp = requests.post(flush_url)
+        resp.raise_for_status()
 
-    def play_user_default_bucket_tutorial(self):
+    def test_user_default_bucket_tutorial(self):
         collection_id = 'tasks-%s' % uuid.uuid4()
-        collection_url = self.collection_url('default', collection_id)
+        collection_url = urljoin(self.server_url,
+                                 '/buckets/default/collections/{}/records'.format(collection_id))
+        task = build_task()
 
-        # Create a new task
+        # Create a task on a default collection
         # 201 created with data and permission.write
-        task = build_task()
-        resp = self.session.post(
-            collection_url,
-            data=json.dumps({'data': task}))
-        self.incr_counter("status-%s" % resp.status_code)
-        self.assertEqual(resp.status_code, 201)
+        resp = self.session.post(collection_url, json={"data": task})
+        resp.raise_for_status()
+        self.assertEquals(resp.status_code, 201)
         record = resp.json()
-        self.assertEqual(record['data']['description'], task['description'])
-        self.assertEqual(record['data']['status'], task['status'])
+
+        self.assertEquals(record['data']['description'], task['description'])
+        self.assertEquals(record['data']['status'], task['status'])
         self.assertIn('write', record['permissions'])
 
         # Create a new one with PUT and If-None-Match: "*"
         task = build_task()
-        record_id = unicode(uuid.uuid4())
-        record_url = self.record_url(record_id, 'default', collection_id)
-        resp = self.session.put(
-            record_url,
-            data=json.dumps({'data': task}),
-            headers={'If-None-Match': '*'})
-        self.incr_counter("status-%s" % resp.status_code)
-        self.assertEqual(resp.status_code, 201)
+        record_id = str(uuid.uuid4())
+        record_url = urljoin(self.server_url,
+                             '{}/{}'.format(collection_url, record_id))
+        resp = self.session.put(record_url, json={'data': task},
+                                headers={'If-None-Match': '*'})
+        resp.raise_for_status()
+        resp.raise_for_status()
+        self.assertEquals(resp.status_code, 201)
         record = resp.json()
-        self.assertEqual(record['data']['description'], task['description'])
-        self.assertEqual(record['data']['status'], task['status'])
+
+        self.assertEquals(record['data']['description'], task['description'])
+        self.assertEquals(record['data']['status'], task['status'])
         self.assertIn('write', record['permissions'])
 
         # Fetch the collection list and see the tasks (save the etag)
         resp = self.session.get(collection_url)
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 200)
-        records = resp.json()['data']
-        self.assertEqual(len(records), 2)
+        record = resp.json()
+        self.assertEqual(len(record['data']), 2)
         etag = resp.headers['ETag']
 
         # Fetch the collection from the Etag and see nothing new
         resp = self.session.get(
             collection_url,
             params={'_since': etag.strip('"')})
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 200)
-        records = resp.json()['data']
-        self.assertEqual(len(records), 0)
+        record = resp.json()
+        self.assertEqual(len(record['data']), 0)
 
         # Update a task
         resp = self.session.patch(
             record_url,
-            data=json.dumps({'data': {'status': 'done'}}))
-        self.incr_counter("status-%s" % resp.status_code)
+            json={'data': {'status': 'done'}})
         self.assertEqual(resp.status_code, 200)
         record = resp.json()
         self.assertEqual(record['data']['status'], 'done')
@@ -84,9 +100,8 @@ def play_user_default_bucket_tutorial(self):
         # Try an update with If-Match on the saved ETag and see it fails
         resp = self.session.patch(
             record_url,
-            data=json.dumps({'data': {'status': 'done'}}),
+            json={'data': {'status': 'done'}},
             headers={'If-Match': etag})
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 412)
         self.assertEqual(resp.headers['ETag'],
                          '"%s"' % record['data']['last_modified'])
@@ -94,52 +109,48 @@ def play_user_default_bucket_tutorial(self):
         # Get the list of records and update the ETag
         resp = self.session.get(
             '%s?_since=%s' % (collection_url, etag.strip('"')))
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 200)
-        records = resp.json()['data']
-        self.assertEqual(len(records), 1)
+        record = resp.json()
+        self.assertEqual(len(record['data']), 1)
         etag = resp.headers['ETag']
 
         # Try an update with If-Match on the new ETag and see it works
         resp = self.session.patch(
             record_url,
-            data=json.dumps({'data': {'status': 'done'}}),
+            json={'data': {'status': 'done'}},
             headers={'If-Match': etag})
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 200)
-        record = resp.json()['data']
+        record = resp.json()
         etag = resp.headers['ETag']
 
         # Delete the record with If-Match
         resp = self.session.delete(
             record_url,
             headers={'If-Match': etag})
-        self.incr_counter("status-%s" % resp.status_code)
+
         self.assertEqual(resp.status_code, 200)
 
         # Try the collection get with the ``_since`` parameter
         resp = self.session.get(
             '%s?_since=%s' % (collection_url, etag.strip('"')),
             headers={'If-None-Match': etag})
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 200)
-        records = resp.json()['data']
-        self.assertEqual(len(records), 1)
-        self.assertIn('deleted', records[0])
+        record = resp.json()
+        self.assertEqual(len(record['data']), 1)
+        self.assertIn('deleted', record['data'][0])
 
         # Delete all the things
         resp = self.session.delete(collection_url)
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 200)
 
-    def play_user_shared_bucket_tutorial(self):
+    def test_user_shared_bucket_tutorial(self):
         bucket_id = 'bucket-%s' % uuid.uuid4()
         collection_id = 'tasks-%s' % uuid.uuid4()
-        collection_url = self.collection_url(bucket_id, collection_id)
+        bucket_url = urljoin(self.server_url, '/buckets/{}'.format(bucket_id))
+        collection_url = '{}/collections/{}/records'.format(bucket_url, collection_id)
 
         # Create a new bucket and check for permissions
-        resp = self.session.put(self.bucket_url(bucket_id))
-        self.incr_counter("status-%s" % resp.status_code)
+        resp = self.session.put(bucket_url)
         # In case of concurrent execution, it may have been created already.
         self.assertIn(resp.status_code, (200, 201))
         record = resp.json()
@@ -149,8 +160,7 @@ def play_user_shared_bucket_tutorial(self):
         permissions = {"record:create": ['system.Authenticated']}
         resp = self.session.put(
             re.sub('/records$', '', collection_url),
-            data=json.dumps({'permissions': permissions}))
-        self.incr_counter("status-%s" % resp.status_code)
+            json={'permissions': permissions})
         # In case of concurrent execution, it may have been created already.
         self.assertIn(resp.status_code, (200, 201))
         record = resp.json()
@@ -159,23 +169,21 @@ def play_user_shared_bucket_tutorial(self):
                       record['permissions']['record:create'])
 
         # Create a new tasks for Alice
-        alice_auth = HTTPBasicAuth('token', 'alice-secret-%s' % uuid.uuid4())
+        alice_auth = ('token', 'alice-secret-%s' % uuid.uuid4())
         alice_task = build_task()
         resp = self.session.post(
             collection_url,
-            data=json.dumps({'data': alice_task}),
+            json={'data': alice_task},
             auth=alice_auth)
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 201)
         record = resp.json()
         self.assertIn('write', record['permissions'])
         alice_task_id = record['data']['id']
 
-        bob_auth = HTTPBasicAuth('token', 'bob-secret-%s' % uuid.uuid4())
+        bob_auth = ('token', 'bob-secret-%s' % uuid.uuid4())
 
         # Bob has no task yet.
         resp = self.session.get(collection_url, auth=bob_auth)
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 200)
         self.assertEqual(len(resp.json()['data']), 0)
 
@@ -183,9 +191,8 @@ def play_user_shared_bucket_tutorial(self):
         bob_task = build_task()
         resp = self.session.post(
             collection_url,
-            data=json.dumps({'data': bob_task}),
+            json={'data': bob_task},
             auth=bob_auth)
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 201)
         record = resp.json()
         self.assertIn('write', record['permissions'])
@@ -194,44 +201,37 @@ def play_user_shared_bucket_tutorial(self):
 
         # Now that he has a task, he should see his.
         resp = self.session.get(collection_url, auth=bob_auth)
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 200)
         records = resp.json()['data']
         self.assertEqual(len(records), 1)
         self.assertEqual(records[0]['id'], bob_task_id)
 
+        record_url = '{}/{}'.format(collection_url, alice_task_id)
+
         # Share Alice's task with Bob
-        resp = self.session.patch(
-            self.record_url(alice_task_id, bucket_id, collection_id),
-            data=json.dumps({'permissions': {'read': [bob_user_id]}}),
-            auth=alice_auth)
-        self.incr_counter("status-%s" % resp.status_code)
+        resp = self.session.patch(record_url,
+                                  json={'permissions': {'read': [bob_user_id]}},
+                                  auth=alice_auth)
         self.assertEqual(resp.status_code, 200)
         record = resp.json()
         self.assertIn('write', record['permissions'])
         alice_task_id = record['data']['id']
 
         # Check that Bob can access it
-        resp = self.session.get(
-            self.record_url(alice_task_id, bucket_id, collection_id),
-            auth=bob_auth)
-        self.incr_counter("status-%s" % resp.status_code)
+        resp = self.session.get(record_url, auth=bob_auth)
         self.assertEqual(resp.status_code, 200)
 
         # Get mary's userid
-        mary_auth = HTTPBasicAuth('token', 'mary-secret-%s' % uuid.uuid4())
-        resp = self.session.get(self.api_url(''), auth=mary_auth)
-        self.incr_counter("status-%s" % resp.status_code)
+        mary_auth = ('token', 'mary-secret-%s' % uuid.uuid4())
+        resp = self.session.get('{}/'.format(self.server_url), auth=mary_auth)
         self.assertEqual(resp.status_code, 200)
         record = resp.json()
         mary_user_id = record['user']['id']
 
         # Allow group creation on bucket
         permissions = {"group:create": ['system.Authenticated']}
-        resp = self.session.put(
-            self.bucket_url(bucket_id),
-            data=json.dumps({'permissions': permissions}))
-        self.incr_counter("status-%s" % resp.status_code)
+        resp = self.session.put(bucket_url,
+                                json={'permissions': permissions})
         self.assertEqual(resp.status_code, 200)
         record = resp.json()
         self.assertIn('group:create', record['permissions'])
@@ -239,35 +239,27 @@ def play_user_shared_bucket_tutorial(self):
                       record['permissions']['group:create'])
 
         # Create Alice's friend group with Bob and Mary
-        resp = self.session.put(
-            self.group_url(bucket_id, 'alices-friends'),
-            data=json.dumps({'data': {'members': [mary_user_id,
-                                                  bob_user_id]}}),
-            auth=alice_auth)
-        self.incr_counter("status-%s" % resp.status_code)
+        group_url = '{}/groups/alices-friends'.format(bucket_url)
+        resp = self.session.put(group_url,
+                                json={'data': {'members': [mary_user_id, bob_user_id]}},
+                                auth=alice_auth)
         self.assertEqual(resp.status_code, 201)
 
         # Give Alice's task permission for that group
-        group_id = self.group_url(bucket_id, 'alices-friends', False)
-        resp = self.session.patch(
-            self.record_url(alice_task_id, bucket_id, collection_id),
-            data=json.dumps({'permissions': {'read': [group_id]}}),
-            auth=alice_auth)
-        self.incr_counter("status-%s" % resp.status_code)
+        group_id = '/buckets/{}/groups/alices-friends'.format(bucket_id)
+        resp = self.session.patch(record_url,
+                                  json={'permissions': {'read': [group_id]}},
+                                  auth=alice_auth)
         self.assertEqual(resp.status_code, 200)
         record = resp.json()
         self.assertIn(group_id, record['permissions']['read'])
 
         # Try to access Alice's task with Mary
-        resp = self.session.get(
-            self.record_url(alice_task_id, bucket_id, collection_id),
-            auth=mary_auth)
-        self.incr_counter("status-%s" % resp.status_code)
+        resp = self.session.get(record_url, auth=mary_auth)
         self.assertEqual(resp.status_code, 200)
 
         # Check that Mary's collection_get sees Alice's task
         resp = self.session.get(collection_url, auth=mary_auth)
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 200)
         records = resp.json()['data']
         self.assertEqual(len(records), 1)
@@ -275,7 +267,6 @@ def play_user_shared_bucket_tutorial(self):
 
         # Check that Bob's collection_get sees both his and Alice's tasks
         resp = self.session.get(collection_url, auth=bob_auth)
-        self.incr_counter("status-%s" % resp.status_code)
         self.assertEqual(resp.status_code, 200)
         records = resp.json()['data']
         self.assertEqual(len(records), 2)
@@ -283,9 +274,7 @@ def play_user_shared_bucket_tutorial(self):
         self.assertIn(alice_task_id, records_ids)
         self.assertIn(bob_task_id, records_ids)
 
-    def check_for_lists(self):
-        # List buckets should be forbidden
-        resp = self.session.get(
-            self.api_url('buckets'))
-        self.incr_counter("status-%s" % resp.status_code)
+    def test_check_for_lists(self):
+        # List buckets should not be forbidden
+        resp = self.session.get('{}/buckets'.format(self.server_url))
         self.assertEqual(resp.status_code, 200)