diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 4484cac62ec6f2702b320d3aa91870e70b4ead54_LmdpdGxhYi1jaS55bWw=..0fb080593f29cba643c41c5eaed814d718f0bbe5_LmdpdGxhYi1jaS55bWw= 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,5 +1,5 @@
 tests-5.2:
    image: octobus/ci-py2-hgext3rd:hg-5.2
    script:
-      - flake8 heptapod setup.py
+      - flake8 heptapod setup.py hgext3rd
       # TODO put py.test in the image
@@ -5,3 +5,5 @@
       # TODO put py.test in the image
-      - pip install --user pytest
-      - ~/.local/bin/py.test
+      # the py.test version combination is the consistent one
+      # provided by Fedora 30 for python2
+      - pip install --user pytest==3.10.1 pytest-cov==2.6.0 hg-evolve==9.2.1
+      - ~/.local/bin/py.test --cov heptapod --cov hgext3rd.heptapod
diff --git a/.hgignore b/.hgignore
index 4484cac62ec6f2702b320d3aa91870e70b4ead54_LmhnaWdub3Jl..0fb080593f29cba643c41c5eaed814d718f0bbe5_LmhnaWdub3Jl 100644
--- a/.hgignore
+++ b/.hgignore
@@ -7,5 +7,6 @@
 build/
 *.egg-info/
 dist/
+venv/
 
 .pytest_cache/
@@ -10,2 +11,4 @@
 
 .pytest_cache/
+.coverage
+htmlcov/
diff --git a/README.md b/README.md
index 4484cac62ec6f2702b320d3aa91870e70b4ead54_UkVBRE1FLm1k..0fb080593f29cba643c41c5eaed814d718f0bbe5_UkVBRE1FLm1k 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,8 @@
 # Heptapod Python components
 
+[![build status](https://dev.heptapod.net/heptapod/py-heptapod/badges/branch/default/build.svg)](https://dev.heptapod.net/heptapod/py-heptapod/commits/branch/heptapod-0-7)
+[![tests coverage](https://dev.heptapod.net/heptapod/py-heptapod/badges/branch/default/coverage.svg)](https://dev.heptapod.net/heptapod/py-heptapod/commits/branch/heptapod-0-7)
+
 
 ## WSGI serving of repositories
 
diff --git a/heptapod/hooks/tests/__init__.py b/heptapod/hooks/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0fb080593f29cba643c41c5eaed814d718f0bbe5_aGVwdGFwb2QvaG9va3MvdGVzdHMvX19pbml0X18ucHk=
--- /dev/null
+++ b/heptapod/hooks/tests/__init__.py
@@ -0,0 +1,1 @@
+# Python package
diff --git a/heptapod/hooks/tests/test_check_publish.py b/heptapod/hooks/tests/test_check_publish.py
new file mode 100644
index 0000000000000000000000000000000000000000..0fb080593f29cba643c41c5eaed814d718f0bbe5_aGVwdGFwb2QvaG9va3MvdGVzdHMvdGVzdF9jaGVja19wdWJsaXNoLnB5
--- /dev/null
+++ b/heptapod/hooks/tests/test_check_publish.py
@@ -0,0 +1,57 @@
+from __future__ import absolute_import
+import pytest
+from mercurial import (
+    error,
+    phases,
+)
+from heptapod.testhelpers import (
+    LocalRepoWrapper,
+    )
+
+
+def init_repo(basedir):
+    return LocalRepoWrapper.init(
+        basedir,
+        config=dict(
+            phases={'publish': 'no'},
+            hooks={'pretxnclose.check_publish':
+                   'python:heptapod.hooks.check_publish.check_publish'
+                   },
+            web={'allow-publish': 'maintainer',
+                 'allow-push': '*',
+                 },
+        ))
+
+
+def switch_user(wrapper, user):
+    wrapper.repo.ui.environ['REMOTE_USER'] = user
+
+
+def test_draft_publish(tmpdir):
+    wrapper = init_repo(tmpdir)
+    switch_user(wrapper, 'someone')
+    ctx = wrapper.write_commit('foo', content='Foo', return_ctx=True)
+    assert ctx.phase() == phases.draft
+
+    with pytest.raises(error.HookAbort) as exc_info:
+        wrapper.set_phase('public', [ctx.hex()])
+    assert 'pretxnclose.check_publish' in exc_info.value.args[0]
+
+    switch_user(wrapper, 'maintainer')
+    wrapper.set_phase('public', [ctx.hex()])
+    assert ctx.phase() == phases.public
+
+
+def test_wrong_hook(tmpdir):
+    wrapper = init_repo(tmpdir)
+    ui = wrapper.repo.ui
+    pretxn = 'pretxnclose.check_publish'
+    hookdef = ui.config('hooks', pretxn)
+    ui.setconfig('hooks', pretxn, '')
+    ui.setconfig('hooks', 'precommit.check_publish', hookdef)
+    # precommit because that one does not swallow exceptions other
+    # than abort
+    with pytest.raises(error.ProgrammingError) as exc_info:
+        wrapper.write_commit('foo')
+
+    assert 'precommit' in exc_info.value.args[0]
diff --git a/heptapod/testhelpers.py b/heptapod/testhelpers.py
new file mode 100644
index 0000000000000000000000000000000000000000..0fb080593f29cba643c41c5eaed814d718f0bbe5_aGVwdGFwb2QvdGVzdGhlbHBlcnMucHk=
--- /dev/null
+++ b/heptapod/testhelpers.py
@@ -0,0 +1,133 @@
+# Copyright 2019 Georges Racinet <georges.racinet@octobus.net>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+"""Helpers for automatic tests.
+
+These allow both high level operation on testing repos, and lower level
+calls and introspections, making it possible to test more exhaustively inner
+code paths that with `.t` tests, which are really functional tests.
+"""
+import os
+from mercurial import (
+    cmdutil,
+    commands,
+    hg,
+    node,
+    phases,
+    ui as uimod,
+)
+import random
+
+# re-exports for stability
+NULL_REVISION = node.nullrev
+NULL_ID = node.nullid
+
+
+def make_ui(base_ui, config=None):
+    # let's make sure we aren't polluted by surrounding settings
+    os.environ['HGRCPATH'] = ''
+    if base_ui is None:
+        ui = uimod.ui.load()
+    else:
+        ui = base_ui.copy()
+    if config is not None:
+        for section_name, section in config.items():
+            for item_name, item_value in section.items():
+                ui.setconfig(section_name, item_name, item_value,
+                             source='tests')
+    return ui
+
+
+class LocalRepoWrapper(object):
+
+    def __init__(self, repo):
+        self.repo = repo
+
+    @classmethod
+    def init(cls, path, base_ui=None, config=None):
+        path = str(path)
+        init = cmdutil.findcmd('init', commands.table)[1][0]
+        ui = make_ui(base_ui, config)
+        init(ui, dest=path)
+        return cls(hg.repository(ui, path))
+
+    @classmethod
+    def load(cls, path, base_ui=None, config=None):
+        path = str(path)
+        ui = make_ui(base_ui, config=config)
+        return cls(hg.repository(ui, path))
+
+    def write_commit(self, rpath,
+                     content=None, message=None,
+                     return_ctx=False,
+                     parent=None, branch=None):
+        """Write content at rpath and commit in one call.
+
+        This is meant to allow fast and efficient preparation of
+        testing repositories. To do so, it goes a bit lower level
+        than the actual commit command, so is not suitable to test specific
+        commit options, especially if through extensions.
+
+        This leaves the working directoy updated at the new commit.
+
+        :param rpath: relative path from repository root. If existing,
+                      will be overwritten by `content`
+        :param content: what's to be written in ``rpath``.
+                        If not specified, will be replaced by random content.
+        :param message: message commit. If not specified, defaults to
+                        ``content``
+        :param parent: binary node value. If specified, the repository is
+                       updated to it first. Useful to produce branching
+                       histories. This is single valued, because the purpose
+                       of this method is not to produce merge commits.
+        :returns: binary node for the resulting commit.
+        """
+        rpath = str(rpath)
+        repo = self.repo
+        path = os.path.join(repo.root, rpath)
+        if parent is not None:
+            self.update_bin(parent)
+        if content is None:
+            content = "random: {}\n\nparent: {}\n".format(
+                random.random(),
+                node.hex(repo.dirstate.p1()))
+        if message is None:
+            message = content
+
+        if branch is not None:
+            self.repo.dirstate.setbranch(branch)
+
+        flags = 'wb' if isinstance(content, bytes) else 'w'
+        with open(path, flags) as fobj:
+            fobj.write(content)
+
+        def commitfun(ui, repo, message, match, opts):
+            return self.repo.commit(message,
+                                    opts.get('user'),
+                                    opts.get('date'),
+                                    match=match,
+                                    editor=False,
+                                    extra=None)
+        new_node = cmdutil.commit(repo.ui, repo, commitfun, (path, ),
+                                  dict(addremove=True,
+                                       message=message))
+        return repo[new_node] if return_ctx else new_node
+
+    def update_bin(self, bin_node):
+        """Update to a revision specified by its node in binary form.
+
+        This is separated in order to avoid ambiguities
+        """
+        # maybe we'll do something lower level later
+        self.update(node.hex(bin_node))
+
+    def update(self, rev):
+        repo = self.repo
+        cmdutil.findcmd('update', commands.table)[1][0](repo.ui, repo, rev)
+
+    def set_phase(self, phase_name, revs, force=True):
+        repo = self.repo
+        opts = dict(force=force, rev=revs)
+        opts.update((phn, phn == phase_name) for phn in phases.cmdphasenames)
+        cmdutil.findcmd('phase', commands.table)[1][0](repo.ui, repo, **opts)
diff --git a/heptapod/tests/test_testhelpers.py b/heptapod/tests/test_testhelpers.py
new file mode 100644
index 0000000000000000000000000000000000000000..0fb080593f29cba643c41c5eaed814d718f0bbe5_aGVwdGFwb2QvdGVzdHMvdGVzdF90ZXN0aGVscGVycy5weQ==
--- /dev/null
+++ b/heptapod/tests/test_testhelpers.py
@@ -0,0 +1,150 @@
+# Copyright 2019 Georges Racinet <georges.racinet@octobus.net>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+from __future__ import absolute_import
+from mercurial import (
+    extensions,
+    phases,
+    ui as uimod,
+)
+from ..testhelpers import (
+    LocalRepoWrapper,
+    NULL_ID,
+    NULL_REVISION,
+    )
+import hgext3rd.heptapod
+
+HGEXT_HEPTA_SOURCE = hgext3rd.heptapod.__file__.replace('.pyc', '.py')
+
+
+def test_init_write_commit(tmpdir):
+    wrapper = LocalRepoWrapper.init(tmpdir)
+    node = wrapper.write_commit('foo', content='Foo', message='Foo committed')
+    ctx = wrapper.repo[node]
+    assert ctx.description() == 'Foo committed'
+    parents = ctx.parents()
+    assert len(parents) == 1
+    assert parents[0].rev() == NULL_REVISION
+    assert parents[0].node() == NULL_ID
+
+    del wrapper, ctx
+
+    reloaded = LocalRepoWrapper.load(tmpdir)
+    rl_ctx = reloaded.repo[node]
+    assert rl_ctx.description() == 'Foo committed'
+
+
+def assert_is_hepta_ext(hepta_ext):
+    assert hepta_ext is not None
+    # it's imported with a different name, hence can't be directly compared
+    # let's also avoid flakiness due to __file__ behaviour depending on
+    # installation context
+    assert hepta_ext.__doc__ == hgext3rd.heptapod.__doc__
+
+
+def test_load_hgrc_extension(tmpdir):
+    LocalRepoWrapper.init(tmpdir)
+    tmpdir.join('.hg', 'hgrc').write('\n'.join((
+        "[extensions]",
+        "heptapod=" + HGEXT_HEPTA_SOURCE,
+    )))
+    wrapper = LocalRepoWrapper.load(tmpdir, config=dict(foo=dict(bar='17')))
+    exts = dict(extensions.extensions(wrapper.repo.ui))
+    assert_is_hepta_ext(exts.get('heptapod'))
+
+    assert wrapper.repo.ui.configint('foo', 'bar') == 17
+
+
+def test_init_baseui_config_extension(tmpdir):
+    ui = uimod.ui.load()
+    ui.setconfig('foo', 'bar', 'yes', source='tests')
+    ui.setconfig('extensions', 'heptapod', HGEXT_HEPTA_SOURCE, source='tests')
+    wrapper = LocalRepoWrapper.init(tmpdir, base_ui=ui)
+
+    assert wrapper.repo.ui.configbool('foo', 'bar')
+    exts = dict(extensions.extensions(wrapper.repo.ui))
+    assert_is_hepta_ext(exts.get('heptapod'))
+
+
+def test_init_config_extension(tmpdir):
+    ui = uimod.ui.load()
+    ui.setconfig('foo', 'bar', 'yes', source='tests')
+    ui.setconfig('extensions', 'heptapod', HGEXT_HEPTA_SOURCE, source='tests')
+    wrapper = LocalRepoWrapper.init(
+        tmpdir,
+        config=dict(foo=dict(bar='yes'),
+                    extensions=dict(heptapod=HGEXT_HEPTA_SOURCE),
+                    ))
+
+    assert wrapper.repo.ui.configbool('foo', 'bar')
+    exts = dict(extensions.extensions(wrapper.repo.ui))
+    assert_is_hepta_ext(exts.get('heptapod'))
+
+
+def test_update(tmpdir):
+    wrapper = LocalRepoWrapper.init(tmpdir)
+    wrapper.write_commit('foo', content='Foo 0')
+    node1 = wrapper.write_commit('foo', content='Foo 1')
+    foo = tmpdir.join('foo')
+    assert foo.read() == 'Foo 1'
+
+    wrapper.update('0')
+    assert foo.read() == 'Foo 0'
+
+    wrapper.update_bin(NULL_ID)
+    assert not foo.isfile()
+
+    wrapper.update_bin(node1)
+    assert foo.read() == 'Foo 1'
+
+
+def test_write_commit_named_branch(tmpdir):
+    """Demonstrate the use of write_commit with parent."""
+    wrapper = LocalRepoWrapper.init(tmpdir)
+    node0 = wrapper.write_commit('foo', content='Foo 0')
+    wrapper.write_commit('foo', content='Foo 1')
+    nodebr = wrapper.write_commit('foo', content='Foo branch',
+                                  parent=node0, branch='other')
+
+    ctxbr = wrapper.repo[nodebr]
+    assert ctxbr.branch() == 'other'
+    assert [c.node() for c in ctxbr.parents()] == [node0]
+
+
+def test_write_commit_wild_branch(tmpdir):
+    """Demonstrate the use of write_commit with parent."""
+    wrapper = LocalRepoWrapper.init(tmpdir)
+    node0 = wrapper.write_commit('foo', content='Foo 0')
+    wrapper.write_commit('foo', content='Foo 1')
+    nodebr = wrapper.write_commit('foo', content='Foo branch',
+                                  parent=node0)
+
+    ctxbr = wrapper.repo[nodebr]
+    assert ctxbr.branch() == 'default'
+    assert [c.node() for c in ctxbr.parents()] == [node0]
+
+
+def test_write_commit_random(tmpdir):
+    """Demonstrate how random content is generated."""
+
+    wrapper = LocalRepoWrapper.init(tmpdir)
+    node0 = wrapper.write_commit('foo')
+    ctx1 = wrapper.write_commit('foo', parent=node0, return_ctx=True)
+    ctx2 = wrapper.write_commit('foo', parent=node0, return_ctx=True)
+
+    assert ctx1.p1() == ctx2.p1()
+    assert ctx1 != ctx2
+
+
+def test_phase(tmpdir):
+    wrapper = LocalRepoWrapper.init(tmpdir)
+    node = wrapper.write_commit('foo', content='Foo 0')
+    ctx = wrapper.repo[node]
+    assert ctx.phase() == phases.draft
+
+    wrapper.set_phase('public', ['.'], force=False)
+    assert ctx.phase() == phases.public
+
+    wrapper.set_phase('draft', ['.'], force=True)
+    assert ctx.phase() == phases.draft