diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
new file mode 100644
index 0000000..e837306
--- /dev/null
+++ b/.github/workflows/main.yaml
@@ -0,0 +1,42 @@
+name: Python package
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    branches:
+      - master
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: [3.6, 3.7]
+    steps:
+    - uses: actions/checkout@v1
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v1
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install .[test]
+    - name: Lint with flake8
+      run: |
+        pip install flake8
+        # stop the build if there are Python syntax errors or undefined names
+        flake8 src setup.py --show-source --statistics
+    - name: Test with pytest
+      run: |
+        pip install pytest
+        pip install pytest-cov
+        pytest --cov=./ --cov-report=xml
+    - uses: codecov/codecov-action@v1.0.5
+      with:
+        token: ${{ secrets.CODECOV_TOKEN }}
+        file: ./coverage.xml
+        flags: unittests
+        name: gh-${{ matrix.python }}
diff --git a/setup.py b/setup.py
index 28e702f..df669f8 100644
--- a/setup.py
+++ b/setup.py
@@ -42,12 +42,17 @@ setup(
         'Topic :: Software Development :: Build Tools',
     ],
     install_requires=[
-        'towncrier @ git+https://github.com/hawkowl/towncrier.git#egg=towncrier',
+        'towncrier @ git+https://github.com/hawkowl/towncrier.git#egg=towncrier',  # noqa
         'pyyaml >= 5.3',
         'aniso8601 >= 8.0.0',
-        'prompt_toolkit >= 3.0.3',
+        'prompt-toolkit >= 3.0.3',
         'Pygments >= 2.5.2',
         'semver >= 2.9.0',
         'fs >= 2.4.11',
     ],
+    extras_require={
+        'test': [
+            'pytest >= 5.3.5',
+        ],
+    },
 )
diff --git a/src/cacofonix/_app.py b/src/cacofonix/_app.py
index dd23792..86663f4 100644
--- a/src/cacofonix/_app.py
+++ b/src/cacofonix/_app.py
@@ -20,7 +20,7 @@ from ._towncrier import (
     render_fragment,
     render_changelog,
     merge_with_existing_changelog)
-from ._types import Fragment, FoundFragment
+from ._types import Fragment, FoundFragment, GuessPair
 from ._effects import SideEffects
 
 
@@ -161,7 +161,7 @@ class Application(object):
         n = 0
         for n, (version_fs, filename) in enumerate(found_fragments, 1):
             try:
-                fragment = self.load_fragment(version_fs.gettext(filename))
+                fragment = self.load_fragment(version_fs.readtext(filename))
                 fragment_type = fragment.get('type')
                 showcontent = self.config.fragment_types.get(
                     fragment_type, {}).get('showcontent', True)
@@ -219,16 +219,11 @@ class Application(object):
                 changelog)
             self.effects.git_stage(changelog_path)
 
-    def guess_version(self, cwd_fs) -> Optional[str]:
+    def guess_version(self, cwd_fs: FS) -> Optional[str]:
         """
         Attempt to guess the software version.
         """
-        guesses = [package_json]
-        for guess in guesses:
-            result = guess(cwd_fs)
-            if result is not None:
-                return result
-        return None
+        return detect_version(cwd_fs)
 
     def known_versions(self) -> List[VersionInfo]:
         """
@@ -252,8 +247,24 @@ def package_json(cwd_fs: FS):
     if cwd_fs.exists('package.json'):
         log.debug('Guessing version with package.json')
         try:
-            return ('package.json',
-                    json.load(cwd_fs.gettext('package.json')).get('version'))
+            with cwd_fs.open('package.json', 'r') as fd:
+                return json.load(fd).get('version')
         except json.JSONDecodeError:
             pass
     return None
+
+
+_default_guesses = [
+    ('package.json', package_json),
+]
+
+
+def detect_version(cwd_fs: FS, _guesses: List[GuessPair]) -> Optional[str]:
+    """
+    Make several attempts to guess the version of the package.
+    """
+    for kind, guess in _guesses:
+        result = guess(cwd_fs)
+        if result is not None:
+            return kind, result
+    return None
diff --git a/src/cacofonix/_effects.py b/src/cacofonix/_effects.py
index 0cdb8c5..fe8ad58 100644
--- a/src/cacofonix/_effects.py
+++ b/src/cacofonix/_effects.py
@@ -108,8 +108,8 @@ def _dry_run_method(name: str):
 
 class DryRunSideEffects(SideEffects):
     """
-    Dry run side effects that change only temporary files or log actions instead
-    of performing them.
+    Dry run side effects that change only temporary files or log actions
+    instead of performing them.
     """
     git_mv = _dry_run_method('git_mv')
     git_stage = _dry_run_method('git_stage')
diff --git a/src/cacofonix/_log.py b/src/cacofonix/_log.py
index 6d90c95..52feea0 100644
--- a/src/cacofonix/_log.py
+++ b/src/cacofonix/_log.py
@@ -13,7 +13,8 @@ def setup_logging(level):
 def _log_method(name):
     def __log_method(*a, **kw):
         global _logger
-        assert _logger is not None
+        if _logger is None:
+            _logger = logging
         return getattr(_logger, name)(*a, **kw)
     return __log_method
 
diff --git a/src/cacofonix/_types.py b/src/cacofonix/_types.py
index 2c5c2db..097ff87 100644
--- a/src/cacofonix/_types.py
+++ b/src/cacofonix/_types.py
@@ -1,4 +1,4 @@
-from typing import Any, Dict, Tuple
+from typing import Any, Dict, Tuple, Callable, Optional
 from fs.base import FS
 
 
@@ -8,3 +8,4 @@ Fragment = Dict[str, Any]
 # OutputType = Literal['markdown', 'rest']
 OutputType = str
 FoundFragment = Tuple[FS, str]
+GuessPair = Tuple[str, Callable[[FS], Optional[str]]]

diff --git a/src/cacofonix/test/__init__.py b/src/cacofonix/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/cacofonix/test/test_version_detection.py b/src/cacofonix/test/test_version_detection.py
new file mode 100644
index 0000000..f521b74
--- /dev/null
+++ b/src/cacofonix/test/test_version_detection.py
@@ -0,0 +1,50 @@
+import json
+import pytest
+from fs import open_fs
+from fs.wrap import read_only
+
+from cacofonix._app import detect_version, package_json
+
+
+class TestDetectVersion:
+    """
+    Tests for `detect_version`.
+    """
+    never = ('never', lambda _: None)
+    always = ('always', lambda _: '1.2.3')
+
+    def test_detect_nothing(self):
+        _guesses = [self.never]
+        with open_fs('mem://') as cwd_fs:
+            assert detect_version(cwd_fs, _guesses) is None
+
+    def test_detect_something(self):
+        _guesses = [self.always]
+        with open_fs('mem://') as cwd_fs:
+            assert detect_version(cwd_fs, _guesses) == (
+                'always', '1.2.3')
+
+
+@pytest.mark.usefixtures('tmpdir')
+class TestPackageJson:
+    """
+    Tests for ``package.json`` version detection.
+    """
+    def test_nonexistent_file(self, tmpdir):
+        with tmpdir.as_cwd():
+            with open_fs('.') as cwd_fs:
+                assert package_json(read_only(cwd_fs)) is None
+
+    def test_missing_version(self, tmpdir):
+        with tmpdir.as_cwd():
+            with open_fs('.') as cwd_fs:
+                content = json.dumps({'name': 'Test'})
+                cwd_fs.writetext('package.json', content)
+                assert package_json(read_only(cwd_fs)) is None
+
+    def test_version(self, tmpdir):
+        with tmpdir.as_cwd():
+            with open_fs('.') as cwd_fs:
+                content = json.dumps({'name': 'Test', 'version': '1.2.3'})
+                cwd_fs.writetext('package.json', content)
+                assert package_json(read_only(cwd_fs)) == '1.2.3'

Version guessing from package.json is broken
```
Traceback (most recent call last):
  File "/usr/local/bin/cacofonix", line 10, in <module>
    sys.exit(main())
  File "/usr/local/lib/python3.7/site-packages/cacofonix/main.py", line 298, in main
    cli()
  File "/usr/local/lib/python3.7/site-packages/click/core.py", line 764, in __call__
    return self.main(*args, **kwargs)
  File "/usr/local/lib/python3.7/site-packages/click/core.py", line 717, in main
    rv = self.invoke(ctx)
  File "/usr/local/lib/python3.7/site-packages/click/core.py", line 1135, in invoke
    sub_ctx = cmd.make_context(cmd_name, args, parent=ctx)
  File "/usr/local/lib/python3.7/site-packages/click/core.py", line 641, in make_context
    self.parse_args(ctx, args)
  File "/usr/local/lib/python3.7/site-packages/click/core.py", line 940, in parse_args
    value, args = param.handle_parse_result(ctx, opts, args)
  File "/usr/local/lib/python3.7/site-packages/click/core.py", line 1477, in handle_parse_result
    self.callback, ctx, self, value)
  File "/usr/local/lib/python3.7/site-packages/click/core.py", line 96, in invoke_param_callback
    return callback(ctx, param, value)
  File "/usr/local/lib/python3.7/site-packages/cacofonix/_cli.py", line 112, in guess_version
    value = app.guess_version(app.effects.cwd_fs())
  File "/usr/local/lib/python3.7/site-packages/cacofonix/_app.py", line 228, in guess_version
    result = guess(cwd_fs)
  File "/usr/local/lib/python3.7/site-packages/cacofonix/_app.py", line 256, in package_json
    json.load(cwd_fs.gettext('package.json')).get('version'))
  File "/usr/local/Cellar/python/3.7.4/Frameworks/Python.framework/Versions/3.7/lib/python3.7/json/__init__.py", line 293, in load
    return loads(fp.read(),
AttributeError: 'str' object has no attribute 'read'
```
src/cacofonix/test/test_version_detection.py::TestDetectVersion::test_detect_nothing src/cacofonix/test/test_version_detection.py::TestDetectVersion::test_detect_something src/cacofonix/test/test_version_detection.py::TestPackageJson::test_nonexistent_file src/cacofonix/test/test_version_detection.py::TestPackageJson::test_missing_version src/cacofonix/test/test_version_detection.py::TestPackageJson::test_version