diff --git a/.gitignore b/.gitignore
index ea8b9bbd958d85ed5a4e2c2682831e6ef432e85b..6326e2c04a9cfa4dadc721b2c5a1d4c927463752 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ __pycache__
 *.pyc
 settings*.cfg
 /data
+.cache
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..26ba66bfa18bbe7e37784f0bf1d118a99c3110c3
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,13 @@
+.PHONY: help db_test test
+
+
+help:
+	@echo "Please use \`make <target>' where <target> is one of"
+	@echo "  db_test    to start the postgres database for test"
+	@echo "  test       to run the tests"
+
+db_test:
+	docker-compose -f docker-compose-test.yml up -d postgres_test
+
+test:
+	docker-compose -f docker-compose-test.yml run --rm web_test
diff --git a/README.rst b/README.rst
index f6f4d2b97c4442a0e282f56635181153cfec8bad..c0284028f7e567f3e2cc0b4805dd3b07469f99be 100644
--- a/README.rst
+++ b/README.rst
@@ -11,7 +11,7 @@ You can use docker for development:
 
 1. Clone the repository
 
-2. Create the database. Data will be stored under "./data" by default.
+2. Create the database. Data will be stored under "./data/dev" by default.
    You can export the PGDATA_VOLUME variable to use another directory::
 
     # Start only postgres so it has time to initialize
@@ -30,6 +30,23 @@ You can use docker for development:
 Once the database has been created, you only need to run `docker-compose
 up` to start the app.
 
+Testing
+-------
+
+1. Create the database. Data will be stored under "./data/test"::
+
+    # Start only postgres so it has time to initialize
+    $ docker-compose -f docker-compose-test.yml up -d postgres_test
+    or
+    $ make db_test
+
+2. Run the tests::
+
+    $ docker-compose -f docker-compose-test.yml run --rm web_test
+    or
+    $ make test
+
+
 Backup & restore
 ----------------
 
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..b37c41aca8e15e92668f784a51ca1cd0dfc3fa8a
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,90 @@
+# -*- coding: utf-8 -*-
+"""
+conftest
+~~~~~~~~
+
+This module defines the main configuration for the tests.
+
+:copyright: (c) 2017 European Spallation Source ERIC
+:license: BSD 2-Clause, see LICENSE for more details.
+
+"""
+import pytest
+from flask_ldap3_login import AuthenticationResponse, AuthenticationResponseStatus
+from app.factory import create_app
+from app.extensions import db
+from app.defaults import defaults
+
+
+@pytest.fixture(scope='session')
+def app(request):
+    config = {
+        'TESTING': True,
+        'WTF_CSRF_ENABLED': False,
+        'SQLALCHEMY_DATABASE_URI': 'postgresql://ics:icstest@postgres_test/inventory_db_test',
+        'INVENTORY_LDAP_GROUPS': {
+            'admin': 'Inventory Admin',
+            'create': 'Inventory User',
+        }
+    }
+    app = create_app(config=config)
+    with app.app_context():
+        db.drop_all()
+        db.engine.execute('CREATE EXTENSION IF NOT EXISTS citext')
+        db.create_all()
+        for instance in defaults:
+            db.session.add(instance)
+        db.session.flush()
+        db.session.expunge_all()
+        db.session.commit()
+        yield app
+        db.session.remove()
+        db.drop_all()
+
+
+@pytest.fixture
+def client(request, app):
+    return app.test_client()
+
+
+# TODO: make this work to clean the database between tests
+# @pytest.fixture(autouse=True)
+# def dbsession(request, monkeypatch):
+#     # Roll back at the end of every test
+#     request.addfinalizer(db.session.remove)
+#     # Prevent the session from closing (make it a no-op) and
+#     # committing (redirect to flush() instead)
+#     monkeypatch.setattr(db.session, 'commit', db.session.flush)
+#     monkeypatch.setattr(db.session, 'remove', lambda: None)
+
+
+@pytest.fixture(autouse=True)
+def no_ldap_connection(monkeypatch):
+    """Make sure we don't make any connection to the LDAP server"""
+    monkeypatch.delattr('flask_ldap3_login.LDAP3LoginManager._make_connection')
+
+
+@pytest.fixture(autouse=True)
+def patch_ldap_authenticate(monkeypatch):
+
+    def authenticate(self, username, password):
+        response = AuthenticationResponse()
+        response.user_id = username
+        response.user_dn = f'cn={username},dc=esss,dc=lu,dc=se'
+        if username == 'admin' and password == 'adminpasswd':
+            response.status = AuthenticationResponseStatus.success
+            response.user_info = {'cn': 'Admin User', 'mail': 'admin@example.com'}
+            response.user_groups = [{'cn': 'Inventory Admin'}]
+        elif username == 'user_rw' and password == 'userrw':
+            response.status = AuthenticationResponseStatus.success
+            response.user_info = {'cn': 'User RW', 'mail': 'user_rw@example.com'}
+            response.user_groups = [{'cn': 'Inventory User'}]
+        elif username == 'user_ro' and password == 'userro':
+            response.status = AuthenticationResponseStatus.success
+            response.user_info = {'cn': 'User RO', 'mail': 'user_ro@example.com'}
+            response.user_groups = [{'cn': 'ESS Employees'}]
+        else:
+            response.status = AuthenticationResponseStatus.fail
+        return response
+
+    monkeypatch.setattr('flask_ldap3_login.LDAP3LoginManager.authenticate', authenticate)
diff --git a/docker-compose-test.yml b/docker-compose-test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..49c2c818a8f13e0ec7aaa03edb1b2be29d6e3057
--- /dev/null
+++ b/docker-compose-test.yml
@@ -0,0 +1,23 @@
+version: '2'
+services:
+  web_test:
+    image: inventory
+    container_name: inventory_web_test
+    build: .
+    command: pytest -v
+    volumes:
+      - .:/app
+    depends_on:
+      - postgres_test
+  postgres_test:
+    image: postgres:9.6
+    container_name: inventory_postgres_test
+    expose:
+      - "5432"
+    volumes:
+      - ./data/test:/var/lib/postgresql/data/pgdata
+    environment:
+      POSTGRES_USER: ics
+      POSTGRES_PASSWORD: icstest
+      POSTGRES_DB: inventory_db_test
+      PGDATA: /var/lib/postgresql/data/pgdata
diff --git a/environment.yml b/environment.yml
index c2a7f72c55836346414354d55eea37ec7e5d3a0e..c81b9419e00859378aecccf7f5409a966f4e557e 100644
--- a/environment.yml
+++ b/environment.yml
@@ -8,7 +8,7 @@ dependencies:
 - conda-forge::certifi=2017.4.17=py36_0
 - conda-forge::click=6.7=py36_0
 - conda-forge::colorama=0.3.9=py36_0
-- conda-forge::flask=0.12=py36_0
+- conda-forge::flask=0.12.2=py36_0
 - conda-forge::flask-login=0.4.0=py36_0
 - conda-forge::flask-sqlalchemy=2.2=py36_1
 - conda-forge::flask-wtf=0.14.2=py36_0
@@ -28,6 +28,8 @@ dependencies:
 - conda-forge::pillow=4.2.1=py36_0
 - conda-forge::pip=9.0.1=py36_0
 - conda-forge::psycopg2=2.7.1=py36_0
+- conda-forge::py=1.4.34=py36_0
+- conda-forge::pytest=3.2.1=py36_0
 - conda-forge::python=3.6.1=3
 - conda-forge::python-dateutil=2.6.0=py36_0
 - conda-forge::python-editor=1.0.3=py36_0
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..530653886eb8ac84983471333471aecd1485efdf
--- /dev/null
+++ b/tests/test_api.py
@@ -0,0 +1,129 @@
+# -*- coding: utf-8 -*-
+"""
+tests.test_api
+~~~~~~~~~~~~~~
+
+This module defines API tests.
+
+:copyright: (c) 2017 European Spallation Source ERIC
+:license: BSD 2-Clause, see LICENSE for more details.
+
+"""
+import json
+import pytest
+
+
+def get(client, url, token=None):
+    response = client.get(
+        url,
+        headers={'Content-Type': 'application/json',
+                 'Authorization': f'Bearer {token}'},
+    )
+    if response.headers['Content-Type'] == 'application/json':
+        response.json = json.loads(response.data)
+    return response
+
+
+def post(client, url, data, token=None):
+    headers = {'Content-Type': 'application/json'}
+    if token is not None:
+        headers['Authorization'] = f'Bearer {token}'
+    response = client.post(url, data=json.dumps(data), headers=headers)
+    if response.headers['Content-Type'] == 'application/json':
+        response.json = json.loads(response.data)
+    return response
+
+
+def login(client, username, password):
+    data = {
+        'username': username,
+        'password': password
+    }
+    return post(client, '/api/login', data)
+
+
+def get_token(client, username, password):
+    response = login(client, username, password)
+    return response.json['access_token']
+
+
+@pytest.fixture()
+def readonly_token(client):
+    return get_token(client, 'user_ro', 'userro')
+
+
+@pytest.fixture()
+def user_token(client):
+    return get_token(client, 'user_rw', 'userrw')
+
+
+@pytest.fixture()
+def admin_token(client):
+    return get_token(client, 'admin', 'adminpasswd')
+
+
+def check_response_message(response, msg, status_code=400):
+    assert response.status_code == status_code
+    try:
+        data = response.json
+    except AttributeError:
+        data = json.loads(response.data)
+    try:
+        message = data['message']
+    except KeyError:
+        # flask-jwt-extended is using "msg" instead of "message"
+        # in its default callbacks
+        message = data['msg']
+    assert message == msg
+
+
+def check_names(response, names):
+    response_names = set(item['name'] for item in response.json)
+    assert set(names) == response_names
+
+
+def test_login(client):
+    response = client.post('/api/login')
+    check_response_message(response, 'Body should be a JSON object')
+    response = post(client, '/api/login', data={'username': 'foo', 'passwd': ''})
+    check_response_message(response, 'Missing mandatory field (username or password)', 422)
+    response = login(client, 'foo', 'invalid')
+    check_response_message(response, 'Invalid credentials', 401)
+    response = login(client, 'user_ro', 'userro')
+    assert response.status_code == 200
+    assert 'access_token' in response.json
+
+
+def test_get_locations(client, readonly_token):
+    response = client.get('/api/locations')
+    check_response_message(response, 'Missing Authorization Header', 401)
+    response = get(client, '/api/locations', 'xxxxxxxxx')
+    check_response_message(response, 'Not enough segments', 422)
+    response = get(client, '/api/locations', readonly_token)
+    check_names(response, ('ICS lab', 'Utgård', 'Site', 'ESS'))
+
+
+def test_create_locations_fail(client, readonly_token):
+    response = client.post('/api/locations')
+    check_response_message(response, 'Missing Authorization Header', 401)
+    response = post(client, '/api/locations', data={}, token='xxxxxxxxx')
+    check_response_message(response, 'Not enough segments', 422)
+    response = post(client, '/api/locations', data={}, token=readonly_token)
+    check_response_message(response, "User doesn't have the required group", 403)
+
+
+def test_create_locations(client, user_token):
+    response = post(client, '/api/locations', data={}, token=user_token)
+    check_response_message(response, "Missing mandatory field 'name'", 422)
+    data = {'name': 'Foo'}
+    response = post(client, '/api/locations', data=data, token=user_token)
+    assert response.status_code == 201
+    assert response.json == {'id': 5, 'name': 'Foo'}
+    response = post(client, '/api/locations', data=data, token=user_token)
+    check_response_message(response, 'IntegrityError', 409)
+    response = post(client, '/api/locations', data={'name': 'foo'}, token=user_token)
+    check_response_message(response, 'IntegrityError', 409)
+    response = post(client, '/api/locations', data={'name': 'FOO'}, token=user_token)
+    check_response_message(response, 'IntegrityError', 409)
+    response = get(client, '/api/locations', user_token)
+    check_names(response, ('ICS lab', 'Utgård', 'Site', 'ESS', 'Foo'))
diff --git a/tests/test_basic.py b/tests/test_basic.py
new file mode 100644
index 0000000000000000000000000000000000000000..ee594471b7af260bab170f6831d96e9a34d0992b
--- /dev/null
+++ b/tests/test_basic.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+"""
+tests.test_basic
+~~~~~~~~~~~~~~~~
+
+This module defines basic web tests.
+
+:copyright: (c) 2017 European Spallation Source ERIC
+:license: BSD 2-Clause, see LICENSE for more details.
+
+"""
+
+
+def login(client, username, password):
+    data = {
+        'username': username,
+        'password': password
+    }
+    return client.post('/login', data=data, follow_redirects=True)
+
+
+def logout(client):
+    return client.get('/logout', follow_redirects=True)
+
+
+def test_login_logout(client):
+    response = login(client, 'unknown', 'invalid')
+    assert b'<title>Login</title>' in response.data
+    response = login(client, 'user_rw', 'invalid')
+    assert b'<title>Login</title>' in response.data
+    response = login(client, 'user_rw', 'userrw')
+    assert b'Welcome to the ICS Inventory!' in response.data
+    assert b'User RW' in response.data
+    response = logout(client)
+    assert b'<title>Login</title>' in response.data
+
+
+def test_index(client):
+    response = client.get('/')
+    assert response.status_code == 302
+    assert '/login' in response.headers['Location']
+    login(client, 'user_ro', 'userro')
+    response = client.get('/')
+    assert b'Welcome to the ICS Inventory!' in response.data
+    assert b'User RO' in response.data