Repository: hedyorg/hedy

Issue:
Rewrite e2e tests into 'unittest' format
(This is a refactoring/cleanup task.)

---------

The end to end tests currently send off a set of HTTP requests in a particular order, and each test depends on the previous one succeeding.

This has a couple of issues:

### Tests aren't very readable

It's not super obvious what a line like this does:

```py
        ['retrieve programs after deleting saved program', 'get', '/programs_list', {}, {}, 200, retrieveProgramsBefore],
```

For example, what do the `{}` in there mean? What does `retrieveProgramsBefore` do? Why is it there?

### Tests aren't self-contained

For example, the following 2 tests:

```py
        ['valid signup', 'post', '/auth/signup', {}, {'username': username, 'password': 'foobar', 'email': username + '@e2e-testing.com'}, 200, successfulSignup],
        # ...
        # 10 unrelated lines follow
        # ...
        ['valid login', 'post', '/auth/login', {}, {'username': username, 'password': 'foobar'}, 200, setSentCookies],
```

Depend on each other. The first one creates a user, and the second one tests that a login with the user that was created before. The fact that this relationship exists is not clear, however. The following would be a more readable of the same test:

```py
def test_login_works():
  # GIVEN
  user_exists(username, 'foobar')

  # THEN
  login(username, 'foobar').assert_successful()
```

(Or something similar, just making up an API here)

Because tests aren't self-contained, we also can't choose to run a single individual test--we must always run the entire suite.

### Progress and error reporting isn't standardized

If tests were in a standardized format, we could use standardized test runners like `py.test` to run individual tests, and have nicer error and progress reporting.

### The purpose of supporting code will become more clear

The  [`e2e_tests.py` file](https://github.com/Felienne/hedy/blob/main/e2e_tests.py) currently has `def getProfile1()` through `def getProfile5()`, with what looks like complicated logic inside. It's not clear what their purpose is, because the logic is far away from the actual test case that it supports.




Discussion:
Here's a good thing about the current test setup that we should probably try to maintain (or at least evaluate if it's necessary or not): they don't duplicate work, and will therefore be fast and there are some assumptions that can be made about state.

A user that gets created at the start of the test run and can thereafter be assumed to always exist, doesn't need to be created twice or recreated. Compare:

```py
def test_login_works():
  # GIVEN
  user_exists(username, 'foobar')

  # THEN
  login(username, 'foobar').assert_successful()


def test_saving_programs_works():
  # GIVEN
  user_exists(username, 'foobar')
  login(username, 'foobar').assert_successful()

  # WHEN
  save_program('myprogram', 5, 'print hallo')
  
  # THEN
  this.expectEquals(list_programs(), ['myprogram')
```

May have some issues that need to be resolved:

* Do we create the same user for both tests? If so, the test that goes second will fail, as the user already exists.
* Do we create a new user for each test? If so, then first of all the tests will be slower since every test is going to have to create a user, and also we will accumulate a whole bunch of test users in our database, which will all need to be cleaned up.
* If, on the other hand, we try to save user accounts and `test_saving_programs_works` reuses an existing user account, and some other test gets added that ALSO tests something around saving programs, then `list_programs()` may all of a sudden start returning 2 or more programs instead of the one the test is expecting.

So there are things that the solution to this would need to take into account and have a good answer for. 

Unfortunately, I don't know the best solution myself yet, but I'm sure someone can come up with something clever here 😊
Hi @rix0rrr ! Thank you for bringing up this topic. These are all valid points. Since I'm the one that got those e2e tests written in place, I might have some explaining to do 😅 

The existing test structure is quite unorthodox, but it has a certain structure. The basic outline is that each test maps to a single HTTP request, and is written as a list. The shape is `[label, method, path, headers, body, expectedCode, afterFunction]`, the `afterFunction` being optional.

Despite this (undocumented) regularity, all your criticisms are valid. I've used opaque names for many of the `afterFunctions` (such as `getProfile5`) and that's just plain lazy. Looking back on these particular functions, their operations are probably too long/specific to be put in a camel cased name, but they could be summarized in a comment at their top. For example, `getProfile5` checks for the modification of programming experience in the profile after a used has marked that (s)he has no programming experience. At the very least I could put those comments at the top of each `afterFunction`. In general, almost all of the code in those functions validates the bodies returned by the server. They also sometimes set state to be used by following functions.

Functionally speaking, I also believe it's a must to have a modular test suite, rather than a whole indivisible. I'm actually now in the process of converting a monolithic suite of e2e tests into a modular one, and it's been totally worth it. Some things I learned that we could apply here:
- It's OK to create and then destroy users at the beginning and end of each module. For running that module itself, there's no performance penalty. And when combining test suites, the only cost is that the tests take longer to run.
- In general, all modules should do their own cleanup after they're done, so that the side effects they generate do not affect other modules. This does start to feel less like a E2E and more like a unit test of a section of the API, and it may well be. In practice, the carrying down of state/side effects from a test to another one in the distant future just makes for murky tests and it's easy to lose track of what you're really testing.
- An alternative is to make an exception just for auth and create a bunch of users at the beginning of the test, and then make each module require the auth module of the tests. However, this should include storing the cookies for each login, in order to reuse them. With the clean-slate approach, most of the time spent by auth is actually spent in logins, not signups, because some interactions require a lot of back and forth (for an example of this, see the teacher/student interaction regarding classes).

I think that, as you mention, it could vastly improve readability if we could group tests in functions that contain a set of requests. Since Python is synchronous by default, this is a much more palatable proposal than in Javascript. We would still need several functions to test (say) the auth functionality, but they could be grouped logically. Whether to consider a function as a test module, or to consider a bunch of functions a module instead, is a good question.

There's two existing features from the current test I do suggest we keep: 1) the stopping of the test suite at the first error, to ensure we don't ignore warnings or even serious errors; 2) a preservation of the exclusiveness of interaction with the API, without using any backdoors to put things in the database. Sometimes it's really annoying to do everything through the API, but in the end it ends up being worth it.

If you have a particular test runner in mind, I could start by creating an alternative suite of tests for a particular module of the app (perhaps auth would be a good one to start) and we could continue the conversation over some refactored code. Would we still be using the existing code for triggering HTTP requests, or do you envision replacing that as well?


Cheers!

I agree with what you're saying.

> 1) the stopping of the test suite at the first error,

This is typically a feature of the test runner, and we'll get this for free. For example, if we use [pytest](https://docs.pytest.org/en/6.2.x/) as the test runner, we can `pytest -x e2e_tests.py` and the `-x` makes it stop at the first error.

> 2) a preservation of the exclusiveness of interaction with the API, without using any backdoors to put things in the database

Yep, I agree.

> If you have a particular test runner in mind

We can separate out "test runner", "test definition framework" and  "assertions library". The "test runner" is the least interesting of the bunch, since it is only a "UI" over the rest, and people can pick their own. They can skip the test runner in favor of their IDE or a different runner of their choice. The choice we'll make here will affect what the build server uses (and how it prints output and when it stops on error), but that's about it. 

So I would say:

* Test runner: [pytest](https://docs.pytest.org/en/6.2.x/)

For test definition framework (how do you define a test to run) and assertions library (how do you write assertions): pytest *does* come with some new capabilities of its own, but I'm thinking that for maximum understandability with other people who might be working on this code base, we should probably just be sticking with the `unittest` library that comes with Python 3 by default. It's not the *most* succinct, and it doesn't have the most elaborate set of assertions, but it's definition good enough and it will be familiar to most people. So I would say:

* Test definitions/assertions: [unittest](https://docs.python.org/3/library/unittest.html)

The biggest challenge is going to be coming up with a set of helper functions that make it easy to express what is happening in a test, while hiding away irrelevant details. The goal should be: any function should take the bare minimum of arguments necessary to express an action from the USER's point of view, and implicitly take care of all the rest.

We should pay extra close attention to lifetime of state and variables. For example: a browser session is going to maintain cookies. We should take care to start with a clean cookie jar at the start of each test, so that for every new test it will look like a fresh browser session and they don't (accidentally or otherwise) depend on tests that have run before and left a cookie in there. If we don't pay attention, what's going to happen is that the test is going to succeed when run as part of the suite, and fail when run individually (or vice versa), and that's going to drive us insane.

> Would we still be using the existing code for triggering HTTP requests, or do you envision replacing that as well?

The mechanisms in the code are good! It's just that there is now one API which needs to handle *all* cases, which makes it intimidating to work with. I'm thinking we should probably have different APIs for different cases.

I think for maximum understandability, we should encourage tests to be written in [given/when/then](https://martinfowler.com/bliki/GivenWhenThen.html) style.

* Given: from the point of view of the user, describes the context in which the test is valid (assumptions). This describes a state.
* When: describes the action the user takes. This describes an action.
* Then: describes what needs to be true after the action has happened. This describes a state.

For example, a given-when-then test would look like this:

```python
class TestLoggingIn(HedyTestSuite):
  def test_logged_in_user_can_load_my_programs_page(self):
    # GIVEN
    self.assume_logged_in()

    # WHEN
    webpage = self.load_page('/profile/programs')
 
    # THEN
    webpage.assert_success()
```

(* In this code, the lines are so trivial that the `# GIVEN` comment lines maybe are superfluous, and if so we can leave them out).

This is reads very clearly: "assume we are logged in. We can then successfully load the 'programs' page.", but how do we even implement this? Assumptions aren't executable. The simple trick is that even though `assume_logged_in` is named from the point of view of the test and describes an assumption, it would execute the necessary steps to make the assumption true. So in this case, it would go through a `login` action (and perhaps even a `register_user` action if necessary). After the function finishes execution, the system is now in the state that the function name describes, and the test can proceed.

I'm guessing the class that makes this possible looks somewhat like this:

```python
class HedyHttpTestSuite:
  """Base class for API tests."""
  def setUp(self):
    self.username = None
    self.http = requests.Session()

  def assume_logged_in(self):
    self.username = 'testuser-' + str(random.random())
    self.register_user(self.username, 'password').assert_success()
    self.login(self.username, 'password').assert_success()

  def login(self, username, password);
    return self.send_form('/auth/login', {
      "username": username,
      "password": password,
    })

  def load_page(self, url):
    return Webpage(self.http.get(url), self)

  def send_form(self, url, form_data=None):
    return Webpage(self.http.post(url, data=form_encode(form_data))


class Webpage:
  """A wrapper around a requests.Response with convenience methods."""
  def __init__(self, response, test):
    self.response = response

    # self.test only exists so that we can access 'test.assertEquals()'
    self.test = test

  def assert_success(self):
    self.test.assertEquals(200, self.response.http_code)
```
Obviously, the actual creation of users might be different: we might remember ones that we've already created and not make a new one for each teest.
By the way, just found out that we can also test Flask apps without going over ACTUAL http: https://flask.palletsprojects.com/en/2.0.x/testing/
Hi @rix0rrr ! Thank you for the super clear guidelines. I'll be working on it and keep you and @Felienne updated. I'll start with the auth, since we need those functions anyway and try to open a PR as early as possible, to get feedback on the overall structure and approach. Cheers!

Resolving patch:
diff --git a/app.py b/app.py
--- a/app.py
+++ b/app.py
@@ -1123,7 +1123,7 @@ def share_unshare_program(user):
     if not isinstance(body.get('id'), str):
         return 'id must be a string', 400
     if not isinstance(body.get('public'), bool):
-        return 'public must be a string', 400
+        return 'public must be a boolean', 400
 
     result = DATABASE.program_by_id(body['id'])
     if not result or result['username'] != user['username']:


Test patch:
diff --git a/e2e_tests.py b/e2e_tests.py
deleted file mode 100644
--- a/e2e_tests.py
+++ /dev/null
@@ -1,539 +0,0 @@
-import requests
-import json
-import random
-from utils import timems
-import urllib.parse
-from config import config
-import sys
-import threading
-import re
-
-# USAGE: python e2e_tests.py [CONCURRENT_TESTS] [alpha|test]
-# Concurrent tests are a way to stress test an environment
-# When no environment is specified, the tests are run against localhost
-
-import argparse
-args = argparse.ArgumentParser ()
-args.add_argument ('--concurrent', help='how many tests to run at the same time (stress testing), default is 1', type=int)
-args.add_argument ('--host', help='Host against which to run the tests (optional), default is localhost')
-args.add_argument ('--endpoint', help='Endpoint against which to run the tests (optional, by default \'--host\' is used)')
-args = args.parse_args ()
-
-host = 'http://localhost:' + str (config ['port']) + '/'
-hosts = {'alpha': 'https://hedy-alpha.herokuapp.com/', 'test': 'https://hedy-test.herokuapp.com/'}
-
-if args.endpoint:
-  host = args.endpoint
-
-elif args.host:
-    if not args.host in hosts:
-        raise Exception ('No such host')
-    host = hosts [sys.argv [2]]
-
-# test structure: [tag, method, path, headers, body, code, after_test_function]
-def request (state, test, counter, username):
-
-    start = timems ()
-
-    if isinstance (threading.current_thread (), threading._MainThread):
-        print ('Start #' + str (counter) + ': ' + test [0])
-
-    # If path, headers or body are functions, invoke them passing them the current state
-    if callable(test[2]):
-        test[2] = test[2] (state)
-
-    if callable (test[3]):
-        test[3] = test[3] (state)
-
-    if callable(test[4]):
-        test[4] = test[4] (state)
-
-    # If no explicit cookie passed, use the one from the state
-    if not 'cookie' in test [3] and 'cookie' in state ['headers']:
-        test [3] ['cookie'] = state ['headers'] ['cookie']
-
-    if isinstance(test[4], dict):
-        test[3] ['content-type'] = 'application/json'
-        test[4] = json.dumps (test [4])
-
-    # We pass the X-Testing header to let the server know that this is a request coming from an E2E test, thus no transactional emails should be sent.
-    test [3] ['X-Testing'] = '1'
-
-    r = getattr (requests, test [1]) (host + test [2], headers=test[3], data=test[4], cookies=state['cookiejar'])
-
-    if 'Content-Type' in r.headers and r.headers ['Content-Type'] == 'application/json':
-        body = r.json ()
-    else:
-        body = r.text
-
-    if r.history and r.history [0]:
-        # This will be the case if there's a redirect
-        code = r.history [0].status_code
-        headers = r.history [0].headers
-        if getattr (r.history [0], '_content'):
-            body = getattr (r.history [0], '_content').decode ('utf-8')
-    else:
-        code = r.status_code
-
-    output = {
-        'code':    code,
-        'headers': r.headers,
-        'body':    body
-    }
-
-    if (code != test[5]):
-        print (output)
-        raise Exception ('A test failed!')
-
-    # Remember all cookies in the cookie jar
-    state['cookiejar'].update(r.cookies)
-
-    if len (test) == 7:
-        test [6] (state, output, username)
-
-    if isinstance (threading.current_thread (), threading._MainThread):
-        print ('Done  #' + str (counter) + ': ' + test [0] + ' - ' + str (r.status_code) + ' (' + str (timems () - start) + 'ms)')
-
-    return output
-
-def run_suite (suite):
-    # We use a random username so that if a test fails, we don't have to do a cleaning of the DB so that the test suite can run again
-    # This also allows us to run concurrent tests without having username conflicts.
-    username = 'user' + str (random.randint (10000, 100000))
-    tests = suite (username)
-    state = {'headers': {}, 'cookiejar': requests.cookies.RequestsCookieJar()}
-    t0 = timems ()
-
-    if not isinstance (tests, list):
-        return print ('Invalid test suite, must be a list.')
-    counter = 1
-
-    def run_test (test, counter):
-        result = request (state, test, counter, username)
-
-    for test in tests:
-        # If test is nested, run a nested loop
-        if not (isinstance (test[0], str)):
-            for subtest in test:
-                run_test (subtest, counter)
-                counter += 1
-        else:
-           run_test (test, counter)
-           counter += 1
-
-    if isinstance (threading.current_thread (), threading._MainThread):
-        print ('Test suite successful! (' + str (timems () - t0) + 'ms)')
-    else:
-        return timems () - t0
-
-def invalidMap (tag, method, path, bodies):
-    output = []
-    counter = 1
-    for body in bodies:
-        output.append (['invalid ' + tag + ' #' + str (counter), method, path, {}, body, 400])
-        counter += 1
-    return output
-
-def successfulSignup (state, response, username):
-    if not 'token' in response ['body']:
-        raise Exception ('No token present')
-    state ['token'] = response ['body'] ['token']
-
-def successfulSignupTeacher (state, response, username):
-    state ['teacher-session'] = response ['headers'] ['Set-Cookie']
-
-def successfulSignupStudent (state, response, username):
-    state ['student-session'] = response ['headers'] ['Set-Cookie']
-
-def getProfile1 (state, response, username):
-    profile = response ['body']
-    if profile ['username'] != username:
-        raise Exception ('Invalid username (getProfile1)')
-    if profile ['email'] != username + '@e2e-testing.com':
-        raise Exception ('Invalid username (getProfile1)')
-    if not profile ['session_expires_at']:
-        raise Exception ('No session_expires_at (getProfile1)')
-    expire = profile ['session_expires_at'] - config ['session'] ['session_length'] * 60 * 1000 - timems ()
-    if expire > 0:
-        raise Exception ('Invalid session_expires_at (getProfile1), too large')
-    # We give the server up to 2s to respond to the query
-    if expire < -2000:
-        raise Exception ('Invalid session_expires_at (getProfile1), too small')
-
-def getProfile2 (state, response, username):
-    profile = response ['body']
-    if profile ['country'] != 'NL':
-        raise Exception ('Invalid country (getProfile2)')
-    if profile ['email'] != username + '@e2e-testing2.com':
-        raise Exception ('Invalid country (getProfile2)')
-    if not 'verification_pending' in profile or profile ['verification_pending'] != True:
-        raise Exception ('Invalid verification_pending (getProfile2)')
-
-def getProfile3 (state, response, username):
-    profile = response ['body']
-    if 'verification_pending' in profile:
-        raise Exception ('Invalid verification_pending (getProfile3)')
-
-def getProfile4 (state, response, username):
-    profile = response ['body']
-    if not 'verification_pending' in profile or profile ['verification_pending'] != True:
-        raise Exception ('Invalid verification_pending (getProfile4)')
-    if not 'prog_experience' in profile or profile ['prog_experience'] != 'yes':
-        raise Exception ('Invalid prog_experience (getProfile4)')
-    if not 'experience_languages' in profile or not isinstance(profile ['experience_languages'], list) or len (profile ['experience_languages']) != 1 or profile ['experience_languages'] [0] != 'python':
-        raise Exception ('Invalid experience_languages (getProfile4)')
-
-def getProfile5 (state, response, username):
-    profile = response ['body']
-    if not 'prog_experience' in profile or profile ['prog_experience'] != 'no':
-        raise Exception ('Invalid prog_experience (getProfile5)')
-    if not 'experience_languages' in profile or not isinstance(profile ['experience_languages'], list) or len (profile ['experience_languages']) != 2 or profile ['experience_languages'] [0] not in ['scratch', 'other_text'] or profile ['experience_languages'] [1] not in ['scratch', 'other_text']:
-        raise Exception ('Invalid experience_languages (getProfile5)')
-
-def emailChange (state, response, username):
-    if not isinstance (response ['body'] ['token'], str):
-        raise Exception ('Invalid country (emailChange)')
-    if response ['body'] ['username'] != username:
-        raise Exception ('Invalid username (emailChange)')
-    state ['token2'] = response ['body'] ['token']
-
-def recoverPassword (state, response, username):
-    if not 'token' in response ['body']:
-        raise Exception ('No token present')
-    state ['token'] = response ['body'] ['token']
-
-def checkMainSessionVars (state, response, username):
-    if not 'session_id' in response ['body'] ['session']:
-        raise Exception ('No session_id set')
-    if not 'proxy_enabled' in response ['body']:
-        raise Exception ('No proxy_enabled variable set')
-    state ['session_id'] = response ['body'] ['session'] ['session_id']
-    state ['proxy_enabled'] = response ['body'] ['proxy_enabled']
-
-def checkTestSessionVars (state, response, username):
-    # If proxying to test is disabled, there is nothing to do.
-    if not state ['proxy_enabled']:
-        return
-    if not 'session_id' in response ['body'] ['session']:
-        raise Exception ('No session_id set')
-    if not 'test_session' in response ['body'] ['session']:
-        raise Exception ('No test_session set')
-    if response ['body'] ['session'] ['session_id'] != state ['session_id']:
-        raise Exception ('session_id from main not passed to test')
-    state ['test_session'] = response ['body'] ['session'] ['test_session']
-
-def checkMainSessionVarsAgain (state, response, username):
-    if not 'session_id' in response ['body'] ['session']:
-        raise Exception ('No session_id set')
-    if response ['body'] ['session'] ['session_id'] != state ['session_id']:
-        raise Exception ('session_id from main not maintained after proxying to test')
-    # If proxying to test is disabled, there is nothing else to do.
-    if not state ['proxy_enabled']:
-        return
-    if not 'test_session' in response ['body'] ['session']:
-        raise Exception ('No test_session set')
-    if response ['body'] ['session'] ['test_session'] != state ['test_session']:
-        raise Exception ('test_session not received by main')
-
-def retrieveProgramsBefore (state, response, username):
-    if not isinstance (response ['body'], dict):
-        raise Exception ('Invalid response body')
-    if not 'programs' in response ['body'] or not isinstance (response ['body'] ['programs'], list):
-        raise Exception ('Invalid programs list')
-    if len (response ['body'] ['programs']) != 0:
-        raise Exception ('Programs list should be empty')
-
-def retrieveProgramsAfter (state, response, username):
-    if not isinstance (response ['body'], dict):
-        raise Exception ('Invalid response body')
-    if not 'programs' in response ['body'] or not isinstance (response ['body'] ['programs'], list):
-        raise Exception ('Invalid programs list')
-    if len (response ['body'] ['programs']) != 1:
-        raise Exception ('Programs list should contain one program')
-    program = response ['body'] ['programs'] [0]
-    state ['program'] = program
-    if not isinstance (program, dict):
-        raise Exception ('Invalid program type')
-    if not 'code' in program or program ['code'] != 'print Hello world':
-        raise Exception ('Invalid program.code')
-    if not 'level' in program or program ['level'] != 1:
-        raise Exception ('Invalid program.level')
-
-def getTeacherClasses1 (state, response, username):
-    if not isinstance (response ['body'], list):
-        raise Exception ('Classes should be a list')
-    if len (response ['body']) != 0:
-        raise Exception ('Classes should be empty')
-
-def getTeacherClasses2 (state, response, username):
-    if len (response ['body']) != 1:
-        raise Exception ('Classes should contain one class')
-    Class = response ['body'] [0]
-    if not isinstance (Class.get ('date'), int):
-        raise Exception ('Class should contain date')
-    if not isinstance (Class.get ('id'), str):
-        raise Exception ('Class should contain id')
-    if not isinstance (Class.get ('link'), str):
-        raise Exception ('Class should contain link')
-    if Class.get ('name') != 'class1':
-        raise Exception ('Invalid class name')
-    if not isinstance (Class.get ('students'), list):
-        raise Exception ('Class should contain a list of students')
-    if len (Class.get ('students')) != 0:
-        raise Exception ('Student list should be empty')
-    if Class.get ('teacher') != 'teacher-' + username:
-        raise Exception ('Invalid teacher')
-
-    state ['classes'] = response ['body']
-
-def getClass1 (state, response, username):
-    Class = response ['body']
-    if not isinstance (Class, dict):
-        raise Exception ('Invalid response body')
-    if not isinstance (Class.get ('link'), str):
-        raise Exception ('Class should contain link')
-    if not isinstance (Class.get ('id'), str):
-        raise Exception ('Class should contain id')
-    if Class.get ('name') != 'class1':
-        raise Exception ('Invalid class name')
-    if not isinstance (Class.get ('students'), list):
-        raise Exception ('Class should contain a list of students')
-    if len (Class.get ('students')) != 0:
-        raise Exception ('Student list should be empty')
-
-def getTeacherClasses3 (state, response, username):
-    Class = response ['body'] [0]
-    if Class.get ('name') != 'class_renamed':
-        raise Exception ('Invalid class name')
-
-def getClass2 (state, response, username):
-    Class = response ['body']
-    if Class.get ('name') != 'class_renamed':
-        raise Exception ('Invalid class name')
-
-def redirectAfterJoin1 (state, response, username):
-    if not re.search ('http://localhost:\d\d\d\d/my-profile', response ['body']):
-        raise Exception ('Invalid redirect')
-
-def createPublicProgram (state, response, username):
-    state ['public_program'] = response ['body'] ['id']
-
-def getStudentClasses1 (state, response, username):
-    classes = response ['body'].get ('student_classes')
-    if not isinstance (classes, list):
-        raise Exception ('Invalid classes list')
-    if len (classes) != 1:
-        raise Exception ('Class list should contain one item')
-    Class = classes [0]
-    if not isinstance (Class, dict):
-        raise Exception ('Invalid class')
-    if Class ['id'] != state ['classes'] [0] ['id']:
-        raise Exception ('Invalid class id')
-    if Class ['name'] != 'class_renamed':
-        raise Exception ('Invalid class name')
-    if len (Class.keys ()) != 2:
-        raise Exception ('Too many fields contained in class')
-
-def getClass3 (state, response, username):
-    students = response ['body'].get ('students')
-    if len (students) != 1:
-        raise Exception ('Student list should contain one student')
-    student = students [0]
-    state ['student'] = student
-    if student.get ('highest_level') != 0:
-        raise Exception ('student.highest_level should be 0')
-    if student.get ('programs') != 0:
-        raise Exception ('student.programs should be 0')
-    if student.get ('latest_shared') != None:
-        raise Exception ('student.latest_shared should be None')
-    if not isinstance (student.get ('last_login'), str):
-        raise Exception ('student.last_login should be a string')
-    if student.get ('username') != 'student-' + username:
-        raise Exception ('Invalid student username')
-
-def getClass4 (state, response, username):
-    student = response ['body'].get ('students') [0]
-    if student.get ('highest_level') != 1:
-        raise Exception ('student.highest_level should be 0')
-    if student.get ('programs') != 2:
-        raise Exception ('student.programs should be 2')
-    if not isinstance (student.get ('latest_shared'), dict):
-        raise Exception ('student.latest_shared should be a list')
-    if student.get ('latest_shared').get ('name') != 'Public program 1':
-        raise Exception ('student.latest_shared.name should be "Public program 1"')
-    link = student.get ('latest_shared').get ('link')
-    if not re.search ('http://localhost:\d\d\d\d/hedy/' + state ['public_program'] + '/view', link):
-        raise Exception ('Invalid student.latest_shared.link ' + link + ', expecting', 'http://localhost:\d\d\d\d/hedy/' + state ['public_program'] + '/view')
-
-def getTeacherClasses4 (state, response, username):
-    Class = response ['body'] [0]
-    if len (Class.get ('students')) != 1:
-        raise Exception ('Student list should contain a student id')
-    if Class.get ('students') [0] != 'student-' + username:
-        raise Exception ('Invalid student username')
-
-def getStudentClasses2 (state, response, username):
-    classes = response ['body'].get ('student_classes')
-    if not isinstance (classes, list):
-        raise Exception ('Invalid classes list')
-    if len (classes) != 0:
-        raise Exception ('Class list should be empty')
-
-def checkJoinLink (state, response, username):
-    if not re.search ('http://localhost:\d\d\d\d/class/' + state ['classes'] [0] ['id'] + '/prejoin/' + state ['classes'] [0] ['link'], response ['body']):
-        raise Exception ('Invalid redirect')
-
-def suite (username):
-    return [
-        # Session variables
-        ['get session vars from main', 'get', '/session_main', {}, {}, 200, checkMainSessionVars],
-        ['get session vars from test', 'get', '/session_test', {}, {}, 200, checkTestSessionVars],
-        ['get session vars from main again', 'get', '/session_main', {}, {}, 200, checkMainSessionVarsAgain],
-        # Main page
-        ['get root', 'get', '/', {}, '', 200],
-        # Admin page
-        ['get admin', 'get', '/admin', {}, {}, 200],
-        # Auth: signup
-        invalidMap ('signup', 'post', '/auth/signup', ['', [], {}, {'username': 1}, {'username': 'user@me', 'password': 'foobar', 'email': 'a@a.com'}, {'username:': 'user: me', 'password': 'foobar', 'email': 'a@a.co'}, {'username': 't'}, {'username': '    t    '}, {'username': username}, {'username': username, 'password': 1}, {'username': username, 'password': 'foo'}, {'username': username, 'password': 'foobar'}, {'username': username, 'password': 'foobar', 'email': 'me@something'}, {'username': username, 'password': 'foobar', 'email': 'me@something.com', 'prog_experience': [2]}, {'username': username, 'password': 'foobar', 'email': 'me@something.com', 'prog_experience': 'foo'}, {'username': username, 'password': 'foobar', 'email': 'me@something.com', 'experience_languages': 'python'}]),
-        ['valid signup', 'post', '/auth/signup', {}, {'username': username, 'password': 'foobar', 'email': username + '@e2e-testing.com'}, 200, successfulSignup],
-        invalidMap ('login', 'post', '/auth/login', ['', [], {}, {'username': 1}, {'username': 'user@me'}, {'username:': 'user: me'}]),
-        # Auth: verify & login
-        ['valid login, invalid credentials', 'post', '/auth/login', {}, {'username': username, 'password': 'password'}, 403],
-        ['verify email (missing fields)', 'get', lambda state: '/auth/verify?' + urllib.parse.urlencode ({'username': 'foobar', 'token': state ['token']}), {}, '', 403],
-        ['verify email (invalid username)', 'get', lambda state: '/auth/verify?' + urllib.parse.urlencode ({'username': 'foobar', 'token': state ['token']}), {}, '', 403],
-        ['verify email (invalid token)', 'get', lambda state: '/auth/verify?' + urllib.parse.urlencode ({'username': username, 'token': 'foobar'}), {}, '', 403],
-        ['verify email', 'get', lambda state: '/auth/verify?' + urllib.parse.urlencode ({'username': username, 'token': state ['token']}), {}, '', 302],
-        ['valid login', 'post', '/auth/login', {}, {'username': username, 'password': 'foobar'}, 200],
-        invalidMap ('change password', 'post', '/auth/change_password', ['', [], {}, {'foo': 'bar'}, {'old_password': 1}, {'old_password': 'foobar'}, {'old_password': 'foobar', 'new_password': 1}, {'old_password': 'foobar', 'new_password': 'short'}]),
-        ['change password', 'post', '/auth/change_password', {}, {'old_password': 'foobar', 'new_password': 'foobar2'}, 200],
-        ['invalid login after password change', 'post', '/auth/login', {}, {'username': username, 'password': 'foobar'}, 403],
-        ['valid login after password change', 'post', '/auth/login', {}, {'username': username + '@e2e-testing.com', 'password': 'foobar2'}, 200],
-        ['logout', 'post', '/auth/logout', {}, {}, 200],
-        ['check that session is no longer valid', 'get', '/profile', {}, '', 403],
-        ['login again', 'post', '/auth/login', {}, {'username': username, 'password': 'foobar2'}, 200],
-        invalidMap ('change password', 'post', '/auth/change_password', ['', [], {}, {'foo': 'bar'}, {'old_password': 1}, {'old_password': 'foobar'}, {'old_password': 'foobar', 'new_password': 1}]),
-        # Auth: profile
-        ['get profile before profile update', 'get', '/profile', {}, {}, 200, getProfile1],
-        invalidMap ('update profile', 'post', '/profile', ['', [], {'email': 'foobar'}, {'birth_year': 'a'}, {'birth_year': 20}, {'country': 'Netherlands'}, {'gender': 0}, {'gender': 'a'}, {'prog_experience': 1}, {'prog_experience': 'foo'}, {'experience_languages': ['python', 'foo']}]),
-        ['change profile with same email', 'post', '/profile', {}, {'email': username + '@e2e-testing.com', 'country': 'US'}, 200],
-        ['change profile with different email', 'post', '/profile', {}, {'email': username + '@e2e-testing2.com', 'country': 'NL'}, 200, emailChange],
-        ['get profile after profile update', 'get', '/profile', {}, {}, 200, getProfile2],
-        ['verify email after email change', 'get', lambda state: '/auth/verify?' + urllib.parse.urlencode ({'username': username, 'token': state ['token2']}), {}, '', 302],
-        # Auth: recover & reset
-        ['get profile after email verification', 'get', '/profile', {}, {}, 200, getProfile3],
-        invalidMap ('recover password', 'post', '/auth/recover', ['', [], {}, {'username': 1}]),
-        ['recover password, invalid user', 'post', '/auth/recover', {}, {'username': 'nosuch'}, 403],
-        ['recover password', 'post', '/auth/recover', {}, {'username': username}, 200, recoverPassword],
-        invalidMap ('reset password', 'post', '/auth/reset', ['', [], {}, {'username': 1}, {'username': 'foobar', 'token': 1}, {'username': 'foobar', 'token': 'some'}, {'username': 'foobar', 'token': 'some', 'password': 1}, {'username': 'foobar', 'token': 'some', 'password': 'short'}]),
-        ['reset password, invalid username', 'post', '/auth/reset', {}, lambda state: {'username': 'foobar', 'token': state ['token'], 'password': 'foobar'}, 403],
-        ['reset password, invalid token', 'post', '/auth/reset', {}, lambda state: {'username': username, 'token': 'foobar', 'password': 'foobar'}, 403],
-        ['reset password, invalid password', 'post', '/auth/reset', {}, lambda state: {'username': username, 'token': state ['token'], 'password': 'short'}, 400],
-        ['reset password', 'post', '/auth/reset', {}, lambda state: {'username': username, 'token': state ['token'], 'password': 'foobar3'}, 200],
-        ['login again after reset', 'post', '/auth/login', {}, {'username': username, 'password': 'foobar3'}, 200],
-        # Programs
-        ['retrieve programs before saving any', 'get', '/programs_list', {}, {}, 200, retrieveProgramsBefore],
-        ['create program', 'post', '/programs', {}, {'code': 'print Hello world', 'name': 'Program 1', 'level': 1}, 200],
-        ['retrieve programs after saving one', 'get', '/programs_list', {}, {}, 200, retrieveProgramsAfter],
-        ['share program', 'post', '/programs/share', {}, lambda state: {'id': state ['program'] ['id'], 'public': True}, 200],
-        ['unshare program', 'post', '/programs/share', {}, lambda state: {'id': state ['program'] ['id'], 'public': False}, 200],
-        ['delete program', 'get', lambda state: '/programs/delete/' + state ['program'] ['id'], {}, {}, 302],
-        ['retrieve programs after deleting saved program', 'get', '/programs_list', {}, {}, 200, retrieveProgramsBefore],
-        ['create program before destroying account', 'post', '/programs', {}, {'code': 'print Hello world', 'name': 'Program 1', 'level': 1}, 200],
-        ['destroy account', 'post', '/auth/destroy', {}, {}, 200],
-        ['get programs without being logged in', 'get', '/programs', {}, {}, 302],
-        # Auth: profile (programming experience)
-        ['valid signup again', 'post', '/auth/signup', {}, {'username': username, 'password': 'foobar', 'email': username + '@e2e-testing.com', 'prog_experience': 'yes', 'experience_languages': ['python']}, 200, successfulSignup],
-        ['get profile after recreating account', 'get', '/profile', {}, {}, 200, getProfile4],
-        ['change profile with different programming experience', 'post', '/profile', {}, {'prog_experience': 'no', 'experience_languages': ['scratch', 'other_text']}, 200],
-        ['get profile after updating experience', 'get', '/profile', {}, {}, 200, getProfile5],
-        ['destroy account', 'post', '/auth/destroy', {}, {}, 200],
-        # Classes
-        ['valid signup as student', 'post', '/auth/signup', {}, {'username': 'student-' + username, 'password': 'foobar', 'email': 'student-' + username + '@e2e-testing.com'}, 200, successfulSignupStudent],
-        ['valid signup as teacher', 'post', '/auth/signup', {}, {'username': 'teacher-' + username, 'password': 'foobar', 'email': 'teacher-' + username + '@e2e-testing.com'}, 200, successfulSignupTeacher],
-        ['create class without being a teacher', 'post', '/class', {}, {'name': 'class1'}, 403],
-        ['mark teacher user as teacher', 'post', '/admin/markAsTeacher', {}, {'username': 'teacher-' + username, 'is_teacher': True}, 200],
-        ['get classes before creating a class', 'get', '/classes', {}, {}, 200, getTeacherClasses1],
-        invalidMap ('create class', 'post', '/class', ['', [], {}, {'name': 1}, {'name': ['foobar']}]),
-        ['create class', 'post', '/class', {}, {'name': 'class1'}, 200],
-        ['get classes after creating a class', 'get', '/classes', {}, {}, 200, getTeacherClasses2],
-        ['get empty class', 'get', lambda state: '/for-teachers/class/' + state ['classes'] [0] ['id'], {}, {}, 200, getClass1],
-        invalidMap ('update class', 'put', lambda state: '/class/' + state ['classes'] [0] ['id'], ['', [], {}, {'name': 1}, {'name': ['foobar']}]),
-        ['update class', 'put', lambda state: '/class/' + state ['classes'] [0] ['id'], {}, {'name': 'class_renamed'}, 200],
-        ['get classes after renaming a class', 'get', '/classes', {}, {}, 200, getTeacherClasses3],
-        ['get renamed class', 'get', lambda state: '/for-teachers/class/' + state ['classes'] [0] ['id'], {}, {}, 200, getClass2],
-        ['join class as non-logged in user', 'get', lambda state: '/class/' + state ['classes'] [0] ['id'] + '/join/' + state ['classes'] [0] ['link'], lambda state: {'cookie': 'foo'}, {}, 403],
-        ['login as student', 'post', '/auth/login', {}, {'username': 'student-' + username, 'password': 'foobar'}, 200],
-        ['join class that does not exist', 'get', lambda state: '/class/foo' + '/join/' + state ['classes'] [0] ['link'], {}, {}, 404],
-        ['join class with invalid link', 'get', lambda state: '/class/' + state ['classes'] [0] ['id'] + '/join/' + 'foo', {}, {}, 404],
-        ['test short class link', 'get', lambda state: '/hedy/l/' + state ['classes'] [0] ['link'], {}, {}, 302, checkJoinLink],
-        ['join class', 'get', lambda state: '/class/' + state ['classes'] [0] ['id'] + '/join/' + state ['classes'] [0] ['link'], {}, {}, 302, redirectAfterJoin1],
-        ['join class again (idempotent call)', 'get', lambda state: '/class/' + state ['classes'] [0] ['id'] + '/join/' + state ['classes'] [0] ['link'], {}, {}, 302, redirectAfterJoin1],
-        ['get profile after joining class', 'get', '/profile', {}, {}, 200, getStudentClasses1],
-        ['login as teacher', 'post', '/auth/login', {}, {'username': 'teacher-' + username, 'password': 'foobar'}, 200],
-        ['get class with a student', 'get', lambda state: '/for-teachers/class/' + state ['classes'] [0] ['id'], {}, {}, 200, getClass3],
-        ['get student programs', 'get', lambda state: '/programs?user=' + state ['student'] ['username'], {}, {}, 200],
-        ['login as student', 'post', '/auth/login', {}, {'username': 'student-' + username, 'password': 'foobar'}, 200],
-        ['create public program (not shared yet)', 'post', '/programs', {}, {'code': 'print Hello world', 'name': 'Public program 1', 'level': 1}, 200, createPublicProgram],
-        ['share public program', 'post', '/programs/share', {}, lambda state: {'id': state ['public_program'], 'public': True}, 200],
-        ['create private program after public program', 'post', '/programs', {}, {'code': 'print Hello world', 'name': 'Private program 1', 'level': 1}, 200],
-        ['login as teacher', 'post', '/auth/login', {}, {'username': 'teacher-' + username, 'password': 'foobar'}, 200],
-        ['get class with a student with a shared program', 'get', lambda state: '/for-teachers/class/' + state ['classes'] [0] ['id'], {}, {}, 200, getClass4],
-        ['get classes after student shared a program', 'get', '/classes', {}, {}, 200, getTeacherClasses4],
-        ['remove non-existing student from class', 'delete', lambda state: '/class/' + state ['classes'] [0] ['id'] + '/student/' + 'foo', {}, {}, 200],
-        ['remove student from class', 'delete', lambda state: '/class/' + state ['classes'] [0] ['id'] + '/student/' + 'student-' + username, {}, {}, 200],
-        ['remove student from class again (idempotent call)', 'delete', lambda state: '/class/' + state ['classes'] [0] ['id'] + '/student/' + 'student-' + username, {}, {}, 200],
-        ['get class that is now again empty', 'get', lambda state: '/for-teachers/class/' + state ['classes'] [0] ['id'], {}, {}, 200, getClass2],
-        ['get student programs if student is no longer in teacher\'s class', 'get', lambda state: '/programs?user=' + state ['student'] ['username'], {}, {}, 403],
-        ['login as student', 'post', '/auth/login', {}, {'username': 'student-' + username, 'password': 'foobar'}, 200],
-        ['get profile after being removed from class', 'get', '/profile', {}, {}, 200, getStudentClasses2],
-        ['join class again', 'get', lambda state: '/class/' + state ['classes'] [0] ['id'] + '/join/' + state ['classes'] [0] ['link'], {}, {}, 302, redirectAfterJoin1],
-        ['login as teacher', 'post', '/auth/login', {}, {'username': 'teacher-' + username, 'password': 'foobar'}, 200],
-        ['delete class', 'delete', lambda state: '/class/' + state ['classes'] [0] ['id'], {}, {}, 200],
-        ['check that short class link was deleted', 'get', lambda state: '/hedy/l/' + state ['classes'] [0] ['link'], {}, {}, 404],
-        ['get classes after deleting the class', 'get', '/classes', {}, {}, 200, getTeacherClasses1],
-        ['destroy teacher account', 'post', '/auth/destroy', {}, {}, 200],
-        ['login as student', 'post', '/auth/login', {}, {'username': 'student-' + username, 'password': 'foobar'}, 200],
-        ['get profile after class being deleted', 'get', '/profile', {}, {}, 200, getStudentClasses2],
-        ['destroy student account', 'post', '/auth/destroy', {}, {}, 200],
-        ['valid signup as student', 'post', '/auth/signup', {}, {'username': 'student-' + username, 'password': 'foobar', 'email': 'student-' + username + '@e2e-testing.com'}, 200, successfulSignupStudent],
-        ['valid signup as teacher', 'post', '/auth/signup', {}, {'username': 'teacher-' + username, 'password': 'foobar', 'email': 'teacher-' + username + '@e2e-testing.com'}, 200, successfulSignupTeacher],
-        ['mark teacher user as teacher', 'post', '/admin/markAsTeacher', {}, {'username': 'teacher-' + username, 'is_teacher': True}, 200],
-        ['login as teacher', 'post', '/auth/login', {}, {'username': 'teacher-' + username, 'password': 'foobar'}, 200],
-        ['create class', 'post', '/class', {}, {'name': 'class1'}, 200],
-        ['get classes to set its id in the state', 'get', '/classes', {}, {}, 200, getTeacherClasses2],
-        ['login as student', 'post', '/auth/login', {}, {'username': 'student-' + username, 'password': 'foobar'}, 200],
-        ['join class', 'get', lambda state: '/class/' + state ['classes'] [0] ['id'] + '/join/' + state ['classes'] [0] ['link'], {}, {}, 302, redirectAfterJoin1],
-        ['destroy student account', 'post', '/auth/destroy', {}, {}, 200],
-        ['login as teacher', 'post', '/auth/login', {}, {'username': 'teacher-' + username, 'password': 'foobar'}, 200],
-        ['get classes after student destroyed their account', 'get', '/classes', {}, {}, 200, getTeacherClasses2],
-        ['destroy teacher account', 'post', '/auth/destroy', {}, {}, 200],
-    ]
-
-if not args.concurrent:
-    run_suite (suite)
-else:
-    counter = 0
-    threads = []
-    results = []
-    errors  = []
-
-    def thread_function (counter):
-        try:
-            ms = run_suite (suite)
-            print ('Finished concurrent test #' + str (counter))
-            results.append (ms)
-        except Exception as e:
-            print ('Concurrent test #' + str (counter), "finished with error")
-            errors.append (e)
-
-    print ('Starting', args.concurrent, 'concurrent tests')
-    while counter < args.concurrent:
-        counter += 1
-        thread = threading.Thread (target=thread_function, args=[counter])
-        thread.start ()
-        threads.append (thread)
-
-    for thread in threads:
-        # join waits until all threads are done
-        thread.join ()
-    print ('Done with', args.concurrent, 'concurrent tests,', len (results), 'OK,', len (errors), 'errors,', round (sum (results) / (len (results) * 1000), 2), 'seconds average')
diff --git a/tests_e2e.py b/tests_e2e.py
new file mode 100644
--- /dev/null
+++ b/tests_e2e.py
@@ -0,0 +1,1002 @@
+# *** NATIVE DEPENDENCIES ***
+import os
+import collections
+import random
+import json
+import re
+import urllib.parse
+from http.cookies import SimpleCookie
+
+# *** LIBRARIES ***
+import unittest
+import requests
+
+# *** HEDY RESOURCES ***
+import utils
+from config import config as CONFIG
+
+# *** GLOBAL VARIABLES ***
+
+HOST = os.getenv('ENDPOINT', 'http://localhost:' + str(CONFIG['port']) + '/')
+if not HOST.endswith('/'): HOST += '/'
+
+# This dict has global scope and holds all created users and their still current sessions (as cookies), for convenient reuse wherever needed
+USERS = {}
+
+# *** HELPERS ***
+
+def request(method, path, headers={}, body='', cookies=None):
+
+    if method not in['get', 'post', 'put', 'delete']:
+        raise Exception('request - Invalid method: ' + str(method))
+
+    # We pass the X-Testing header to let the server know that this is a request coming from an E2E test, thus no transactional emails should be sent.
+    headers['X-Testing'] = '1'
+
+    # If sending an object as body, stringify it and set the proper content-type header
+    if isinstance(body, dict):
+        headers['content-type'] = 'application/json'
+        body = json.dumps(body)
+
+    start = utils.timems()
+
+    response = getattr(requests, method)(HOST + path, headers=headers, data=body, cookies=cookies)
+
+    # Remember all cookies in the cookie jar
+    if cookies is not None:
+        cookies.update(response.cookies)
+
+    ret = {'time': utils.timems() - start}
+
+    if response.history and response.history[0]:
+        # This code branch will be executed if there is a redirect
+        ret['code']    = response.history[0].status_code
+        ret['headers'] = response.history[0].headers
+        if getattr(response.history[0], '_content'):
+            # We can assume that bodies returned from redirected responses are always plain text, since no JSON endpoint in the server is reachable through a redirect.
+            ret['body'] = getattr(response.history[0], '_content').decode('utf-8')
+    else:
+        ret['code']    = response.status_code
+        ret['headers'] = response.headers
+        if 'Content-Type' in response.headers and response.headers['Content-Type'] == 'application/json':
+            ret['body'] = response.json()
+        else:
+            ret['body'] = response.text
+
+    return ret
+
+class AuthHelper(unittest.TestCase):
+    def setUp(self):
+        """SetUp gets called on a fresh instance of this object, before each test.
+
+        We have a collection of users in the USER global array. However, we store
+        the cookies for individual users, so that cookies from different sessions don't
+        interfere with each other (i.e., every user must login again in each test).
+        """
+        self.user_cookies = collections.defaultdict(requests.cookies.RequestsCookieJar)
+        self.user = None
+
+    @property
+    def username(self):
+        return self.user['username'] if self.user else None
+
+    def make_username(self):
+        # We create usernames with a random component so that if a test fails, we don't have to do a cleaning of the DB so that the test suite can run again
+        # This also allows us to run concurrent tests without having username conflicts.
+        username = 'user' + str(random.randint(10000, 100000))
+        return username
+
+    # If user with `username` exists, return it. Otherwise, create it.
+    def assert_user_exists(self, username):
+        if not isinstance(username, str):
+            raise Exception('AuthHelper.assert_user_exists - Invalid username: ' + str(username))
+
+        if username in USERS:
+            return USERS[username]
+        body = {'username': username, 'email': username + '@hedy.com', 'password': 'foobar'}
+        response = request('post', 'auth/signup', {}, body, cookies=self.user_cookies[username])
+
+        # It might sometimes happen that by the time we attempted to create the user, another test did it already.
+        # In this case, we get a 403. We invoke the function recursively.
+        if response['code'] == 403:
+            return self.assert_user_exists(username)
+
+        # Store the user & also the verify token for use in upcoming tests
+        USERS[username] = body
+
+        USERS[username]['verify_token'] = response['body']['token']
+        return USERS[username]
+
+    # Returns the first created user, if any; otherwise, creates one.
+    def get_any_user(self):
+        if len(USERS.keys()) > 0:
+            return USERS[next(iter(USERS))]
+        return self.assert_user_exists(self.make_username())
+
+    def get_any_logged_user(self):
+        # If there's no logged in user, we login the user
+        user = self.get_any_user()
+        return self.login_user(user['username'])
+
+    def login_user(self, username):
+        """Login another user. Does not BECOME that user for all subsequent request."""
+        self.assert_user_exists(username)
+        user = USERS[username]
+        request('post', 'auth/login', {}, {'username': user['username'], 'password': user['password']}, cookies=self.user_cookies[username])
+        return user
+
+    def given_specific_user_is_logged_in(self, username):
+        """Become this specific user for all subsequent calls."""
+        self.user = self.login_user(username)
+
+    def get_hedy_cookie(self, cookie_string):
+        cookie = SimpleCookie()
+        cookie.load(cookie_string)
+
+        for key, cookie in cookie.items():
+            if key == CONFIG['session']['cookie_name']:
+                return cookie
+
+    def given_fresh_user_is_logged_in(self):
+        username = self.make_username()
+        self.user = self.login_user(username)
+
+    def make_current_user_teacher(self):
+        """Mark the current user as teacher.
+
+        Need to log in again to refresh the session.
+        """
+        self.post_data('admin/markAsTeacher', {'username': self.username, 'is_teacher': True})
+        return self.login_user(self.username)
+
+    def given_user_is_logged_in(self):
+        self.user = self.get_any_logged_user()
+
+    def given_teacher_is_logged_in(self):
+        self.given_user_is_logged_in()
+        self.make_current_user_teacher()
+
+    def given_fresh_teacher_is_logged_in(self):
+        self.given_fresh_user_is_logged_in()
+        self.make_current_user_teacher()
+
+    def given_any_user(self):
+        self.user = self.get_any_user()
+
+    def switch_user(self, user):
+        self.user = self.login_user(user['username'])
+
+    def post_data(self, path, body, expect_http_code=200, no_cookie=False, return_headers=False, put_data=False):
+        cookies = self.user_cookies[self.username] if self.username and not no_cookie else None
+
+        method = 'put' if put_data else 'post'
+        response = request(method, path, body=body, cookies=cookies)
+        self.assertEqual(response['code'], expect_http_code, f'While {method}ing {body} to {path} (user: {self.username})')
+
+        return response['headers'] if return_headers else response['body']
+
+    def get_data(self, path, expect_http_code=200, no_cookie=False, return_headers=False):
+        cookies = self.user_cookies[self.username] if self.username and not no_cookie else None
+        response = request('get', path, body='', cookies=cookies)
+
+        self.assertEqual(response['code'], expect_http_code, f'While reading {path} (user: {self.username})')
+
+        return response['headers'] if return_headers else response['body']
+
+    def destroy_current_user(self):
+        assert self.username is not None
+        self.post_data('auth/destroy', '')
+        # Remove any records of this user
+        USERS.pop(self.username)
+
+# *** TESTS ***
+
+class TestPages(AuthHelper):
+    def test_get_main_page(self):
+        # WHEN attempting to get the main page
+        # THEN receive an OK response code from the server
+        self.get_data('/')
+
+    def test_get_admin_page(self):
+        # WHEN attempting to get the admin page
+        # THEN receive an OK response code from the server
+        # (Note: this only happens in a dev environment)
+        self.get_data('/admin')
+
+class TestSessionVariables(AuthHelper):
+    def test_get_session_variables(self):
+        # WHEN getting session variables from the main environment
+        body = self.get_data('/session_main')
+
+        # THEN the body should contain a `session` with `session_id` and a `proxy_enabled` field
+        self.assertIn('session', body)
+        self.assertIn('session_id', body['session'])
+        self.assertIn('proxy_enabled', body)
+
+        session = body['session']
+        proxy_enabled = body['proxy_enabled']
+
+        # WHEN getting session variables from the test environment
+        test_body = self.get_data('/session_test')
+        if not proxy_enabled:
+            # If proxying to test is disabled, there is nothing else to test.
+            return
+
+        # THEN the body should contain a `session` with `session_id` and a `test_session` field
+        self.assertIn('session', test_body)
+        self.assertIn('session_id', test_body['session'])
+        self.assertIn('test_session', test_body['session'])
+        self.assertEquals(test_body['session']['session_id'], session['id'])
+
+        # WHEN getting session variables from the main environment
+        body = self.get_data('/session_main')
+        # THEN the body should have a session with a session_id that is still the same and a `test_session` field as well
+        self.assertEqual(body['session']['session_id'], session['id'])
+        self.assertEqual(body['session']['test_session'], test_body['session']['session_id'])
+
+class TestAuth(AuthHelper):
+    def test_invalid_signups(self):
+        # GIVEN a valid username
+        username = self.make_username()
+        # WHEN attempting signups with invalid bodies
+        invalid_bodies = [
+            '',
+            [],
+            {},
+            {'username': 1},
+            {'username': 'user@me', 'password': 'foobar', 'email': 'a@a.com'},
+            {'username:': 'user: me', 'password': 'foobar', 'email': 'a@a.co'},
+            {'username': 't'},
+            {'username': '    t    '},
+            {'username': username},
+            {'username': username, 'password': 1},
+            {'username': username, 'password': 'foo'},
+            {'username': username, 'password': 'foobar'},
+            {'username': username, 'password': 'foobar', 'email': 'me@something'},
+            {'username': username, 'password': 'foobar', 'email': 'me@something.com', 'prog_experience':[2]},
+            {'username': username, 'password': 'foobar', 'email': 'me@something.com', 'prog_experience': 'foo'},
+            {'username': username, 'password': 'foobar', 'email': 'me@something.com', 'experience_languages': 'python'}
+        ]
+        for invalid_body in invalid_bodies:
+            # THEN receive an invalid response code from the server
+            self.post_data('auth/signup', invalid_body, expect_http_code=400)
+
+    def test_signup(self):
+        # GIVEN a valid username and signup body
+        username = self.make_username()
+        user = {'username': username, 'email': username + '@hedy.com', 'password': 'foobar'}
+
+        # WHEN signing up a new user
+        # THEN receive an OK response code from the server
+        body = self.post_data('auth/signup', user)
+
+        # THEN receive a body containing a token
+        self.assertIsInstance(body, dict)
+        self.assertIsInstance(body['token'], str)
+
+        # FINALLY Store the user and its token for upcoming tests
+        user['verify_token'] = body['token']
+        USERS[username] = user
+
+    def test_invalid_login(self):
+        # WHEN attempting logins with invalid bodies
+        invalid_bodies = [
+            '',
+            [],
+            {},
+            {'username': 1},
+            {'username': 'user@me'},
+            {'username:': 'user: me'}
+        ]
+        for invalid_body in invalid_bodies:
+            # THEN receive an invalid response code from the server
+            self.post_data('auth/login', invalid_body, expect_http_code=400)
+
+    def test_login(self):
+        # GIVEN an existing user
+        self.given_any_user()
+
+        # WHEN logging in the user
+        # THEN receive an OK response code from the server
+        headers = self.post_data('auth/login', {'username': self.username, 'password': self.user['password']}, return_headers=True)
+
+        # THEN validate the cookie sent in the response
+        self.assertIsInstance(headers['Set-Cookie'], str)
+        hedy_cookie = self.get_hedy_cookie(headers['Set-Cookie'])
+        self.assertNotEqual(hedy_cookie, None)
+        self.assertEqual(hedy_cookie['httponly'], True)
+        self.assertEqual(hedy_cookie['path'], '/')
+        self.assertEqual(hedy_cookie['samesite'], 'Lax,')
+
+    def test_invalid_verify_email(self):
+        # GIVEN a new user
+        # (we create a new user to ensure that the verification flow hasn't been done for this user yet)
+        self.given_fresh_user_is_logged_in()
+
+        # WHEN submitting invalid verifications
+        invalid_verifications = [
+            # Missing token
+            {'username': self.username},
+            # Missing username
+            {'token': self.user['verify_token']},
+        ]
+
+        for invalid_verification in invalid_verifications:
+            # THEN receive an invalid response code from the server
+            self.get_data('auth/verify?' + urllib.parse.urlencode(invalid_verification), expect_http_code=400)
+
+        # WHEN submitting well-formed verifications with invalid values
+        incorrect_verifications = [
+            # Invalid username
+            {'username': 'foobar', 'token': self.user['verify_token']},
+            # Invalid token
+            {'username': self.username, 'token': 'foobar'}
+        ]
+
+        for incorrect_verification in incorrect_verifications:
+            # THEN receive a forbidden response code from the server
+            self.get_data('auth/verify?' + urllib.parse.urlencode(incorrect_verification), expect_http_code=403)
+
+    def test_verify_email(self):
+        # GIVEN a new user
+        # (we create a new user to ensure that the verification flow hasn't been done for this user yet)
+        self.given_fresh_user_is_logged_in()
+
+        # WHEN attepting to verify the user
+        # THEN receive a redirect from the server taking us to `/`
+        headers = self.get_data('auth/verify?' + urllib.parse.urlencode({'username': self.username, 'token': self.user['verify_token']}), expect_http_code=302, return_headers=True)
+        self.assertEqual(headers['location'], HOST)
+
+        # WHEN attepting to verify the user again (the operation should be idempotent)
+        # THEN (again) receive a redirect from the server taking us to `/`
+        headers = self.get_data('auth/verify?' + urllib.parse.urlencode({'username': self.username, 'token': self.user['verify_token']}), expect_http_code=302, return_headers=True)
+        self.assertEqual(headers['location'], HOST)
+
+        # WHEN retrieving profile to see that the user is no longer marked with `verification_pending`
+        self.given_specific_user_is_logged_in(self.username)
+        profile = self.get_data('profile')
+
+        # THEN check that the `verification_pending` has been removed from the user profile
+        self.assertNotIn('verification_pending', profile)
+
+        # FINALLY remove token from user since it's already been used.
+        self.user.pop('verify_token')
+
+    def test_logout(self):
+        # GIVEN a logged in user
+        self.given_user_is_logged_in()
+
+        # WHEN logging out the user
+        # THEN receive an OK response code from the server
+        self.post_data('auth/logout', '')
+
+        # WHEN retrieving the user profile with the same cookie
+        # THEN receive a forbidden response code from the server
+        self.get_data('profile', expect_http_code=403)
+
+    def test_destroy_account(self):
+        # GIVEN a logged in user
+        self.given_user_is_logged_in()
+
+        # WHEN deleting the user account
+        # THEN receive an OK response code from the server
+        self.destroy_current_user()
+
+        # WHEN retrieving the profile of the user
+        # THEN receive a forbidden response code from the server
+        self.get_data('profile', expect_http_code=403)
+
+    def test_invalid_change_password(self):
+        # GIVEN a logged in user
+        self.given_user_is_logged_in()
+
+        # WHEN attempting signups with invalid bodies
+        invalid_bodies = [
+            '',
+            [],
+            {},
+            {'old_password': 123456},
+            {'old_password': 'pass1'},
+            {'old_password': 'pass1', 'new_password': 123456},
+            {'old_password': 'pass1', 'new_password': 'short'},
+        ]
+
+        for invalid_body in invalid_bodies:
+            # THEN receive an invalid response code from the server
+            self.post_data('auth/change_password', invalid_body, expect_http_code=400)
+
+        # WHEN attempting to change password without sending the correct old password
+        # THEN receive an invalid response code from the server
+        self.post_data('auth/change_password', {
+            'old_password': 'password',
+            'new_password': self.user['password'] + 'foo'
+        }, expect_http_code=403)
+
+    def test_change_password(self):
+        # GIVEN a logged in user
+        self.given_user_is_logged_in()
+
+        # WHEN attempting to change the user's password
+        new_password = 'pas1234'
+        # THEN receive an OK response code from the server
+        self.post_data('auth/change_password', {'old_password': self.user['password'], 'new_password': 'pas1234'})
+
+        # WHEN attempting to login with old password
+        # THEN receive a forbidden response code from the server
+        self.post_data('auth/login', {'username': self.username, 'password': self.user['password']}, expect_http_code=403)
+
+        # GIVEN the same user
+
+        # WHEN attempting to login with new password
+        # THEN receive an OK response code from the server
+        self.post_data('auth/login', {'username': self.username, 'password': new_password})
+
+        # FINALLY update password on user
+        self.user['password'] = new_password
+
+    def test_profile_get(self):
+        # GIVEN a new user
+        # (we create a new user to ensure that the user has a clean profile)
+        self.given_fresh_user_is_logged_in()
+
+        # WHEN retrieving the user profile
+        # THEN receive an OK response code from the server
+        profile = self.get_data('profile')
+
+        # THEN check that the fields returned by the server have the correct values
+        self.assertIsInstance(profile, dict)
+        self.assertEqual(profile['username'], self.username),
+        self.assertEqual(profile['email'],    self.user['email']),
+        self.assertEqual(profile['verification_pending'], True)
+        self.assertIsInstance(profile['student_classes'], list)
+        self.assertEqual(len(profile['student_classes']), 0)
+        self.assertIsInstance(profile['session_expires_at'], int)
+
+    def test_invalid_profile_modify(self):
+        # GIVEN a logged in user
+        self.given_user_is_logged_in()
+
+        # WHEN attempting profile modifications with invalid bodies
+        invalid_bodies = [
+            '',
+            [],
+            {'email': 'foobar'},
+            {'birth_year': 'a'},
+            {'birth_year': 20},
+            {'country': 'Netherlands'},
+            {'gender': 0},
+            {'gender': 'a'},
+            {'prog_experience': 1},
+            {'prog_experience': 'foo'},
+            {'prog_experience': True},
+            {'experience_languages': 'python'},
+            {'experience_languages': ['python', 'foo']}
+        ]
+
+        for invalid_body in invalid_bodies:
+            # THEN receive an invalid response code from the server
+            self.post_data('profile', invalid_body, expect_http_code=400)
+
+    def test_profile_modify(self):
+        # GIVEN a new user
+        # (we create a new user to ensure that the user has a clean profile)
+        self.given_fresh_user_is_logged_in()
+
+        # WHEN submitting valid profile changes
+        profile_changes = {
+           'birth_year': 1989,
+           'country': 'NL',
+           'gender': 'o',
+           'prog_experience': 'yes',
+           'experience_languages': ['python', 'other_block']
+        }
+
+        for key in profile_changes:
+            body = {}
+            body[key] = profile_changes[key]
+            # THEN receive an OK response code from the server
+            self.post_data('profile', body)
+
+            # WHEN retrieving the profile
+            profile = self.get_data('profile')
+            # THEN confirm that our modification has been stored by the server and returned in the latest version of the profile
+            self.assertEqual(profile[key], profile_changes[key])
+
+        # WHEN updating the user's email
+        # (we check email change separately since it involves a flow with a token)
+        # THEN receive an OK response code from the server
+        new_email = self.username + '@newhedy.com'
+        body = self.post_data('profile', {'email': new_email})
+
+        # THEN confirm that the server replies with an email verification token
+        self.assertIsInstance(body['token'], str)
+
+        # FINALLY update the email & email verification token on user
+        self.user['email'] = new_email
+        self.user['verify_token'] = body['token']
+
+    def test_invalid_recover_password(self):
+        # GIVEN an existing user
+        self.given_any_user()
+
+        # WHEN attempting a password recovery with invalid bodies
+        invalid_bodies = [
+            '',
+            [],
+            {},
+            {'username': 1}
+        ]
+
+        for invalid_body in invalid_bodies:
+            # THEN receive an invalid response code from the server
+            self.post_data('auth/recover', invalid_body, expect_http_code=400)
+
+        # WHEN attempting a password recovery with a non-existing username
+        # THEN receive a forbidden response code from the server
+        self.post_data('auth/recover', {'username': self.make_username()}, expect_http_code=403)
+
+    def test_recover_password(self):
+        # GIVEN an existing user
+        self.given_any_user()
+
+        # WHEN attempting a password recovery
+        # THEN receive an OK response code from the server
+        body = self.post_data('auth/recover', {'username': self.username})
+        # THEN check that we have received a password recovery token from the server
+        self.assertIsInstance(body['token'], str)
+
+    def test_invalid_reset_password(self):
+        # GIVEN an existing user
+        self.given_any_user()
+
+        # WHEN attempting a password reset with invalid bodies
+        invalid_bodies = [
+            '',
+            [],
+            {},
+            {'username': 1},
+            {'username': 'foobar', 'token': 1},
+            {'username': 'foobar', 'token': 'some'},
+            {'username': 'foobar', 'token': 'some', 'password': 1},
+            {'username': 'foobar', 'token': 'some', 'password': 'short'}
+        ]
+
+        for invalid_body in invalid_bodies:
+            # THEN receive an invalid response code from the server
+            self.post_data('auth/reset', invalid_body, expect_http_code=400)
+
+        # WHEN attempting a password reset with an invalid token
+        # THEN receive a forbidden response code from the server
+        self.post_data('auth/reset', {'username': self.username, 'password': '123456', 'token': 'foobar'}, expect_http_code=403)
+
+    def test_reset_password(self):
+        # GIVEN an existing user
+        self.given_any_user()
+
+        # WHEN attempting a password reset with a valid username & token combination
+        new_password = 'pas1234'
+        recover_token = self.post_data('auth/recover', {'username': self.username})['token']
+        # THEN receive an OK response code from the server
+        self.post_data('auth/reset', {'username': self.username, 'password': new_password, 'token': recover_token})
+
+        # WHEN attempting a login with the new password
+        # THEN receive an OK response code from the server
+        self.post_data('auth/login', {'username': self.username, 'password': new_password})
+
+        # FINALLY update user's password and attempt login with new password
+        self.user['password'] = new_password
+
+class TestProgram(AuthHelper):
+    def test_invalid_get_programs(self):
+        # GIVEN a logged in user
+        self.given_user_is_logged_in()
+
+        # WHEN retrieving own programs but without sending a cookie
+        # THEN receive a forbidden response code from the server
+        self.get_data('programs_list', expect_http_code=403, no_cookie=True)
+
+    def test_get_programs(self):
+        # GIVEN a logged in user
+        self.given_user_is_logged_in()
+
+        # WHEN retrieving own programs sending a cookie
+        # THEN receive an OK response code from the server
+        body = self.get_data('programs_list')
+
+        # THEN verify that the server sent a body that is an object of the shape `{programs:[...]}`.
+        self.assertIsInstance(body, dict)
+        self.assertIsInstance(body['programs'], list)
+
+    def test_invalid_create_program(self):
+        # GIVEN a logged in user
+        self.given_user_is_logged_in()
+
+        # WHEN attempting to create an invalid program
+        invalid_bodies = [
+            '',
+            [],
+            {},
+            {'code': 1},
+            {'code':['1']},
+            {'code': 'hello world'},
+            {'code': 'hello world', 'name': 1},
+            {'code': 'hello world', 'name': 'program 1'},
+            {'code': 'hello world', 'name': 'program 1', 'level': '1'},
+            {'code': 'hello world', 'name': 'program 1', 'level': 1, 'adventure_name': 1},
+        ]
+
+        for invalid_body in invalid_bodies:
+            # THEN receive an invalid response code from the server
+            self.post_data('programs', invalid_body, expect_http_code=400)
+
+        # WHEN submitting a program without being logged in
+        # THEN receive a forbidden response code from the server
+        self.post_data('programs', {'code': 'hello world', 'name': 'program 1', 'level': 1}, expect_http_code=403, no_cookie=True)
+
+    def test_create_program(self):
+        # GIVEN a new user
+        # (we create a new user to ensure that the user has no programs yet)
+        self.given_fresh_user_is_logged_in()
+
+        # WHEN submitting a valid program
+        program = {'code': 'hello world', 'name': 'program 1', 'level': 1}
+        # THEN receive an OK response code from the server
+        program = self.post_data('programs', program)
+        # THEN verify that the returned program has both a name and an id
+        self.assertIsInstance(program, dict)
+        self.assertIsInstance(program['id'], str)
+        self.assertIsInstance(program['name'], str)
+
+        # WHEN retrieving programs after saving a program
+        saved_programs = self.get_data('programs_list')['programs']
+
+        # THEN verify that the program we just saved is in the list
+        self.assertEqual(len(saved_programs), 1)
+        saved_program = saved_programs[0]
+        for key in program:
+            self.assertEqual(program[key], saved_program[key])
+
+    def test_invalid_make_program_public(self):
+        # GIVEN a logged in user
+        self.given_user_is_logged_in()
+
+        # WHEN attempting to share a program with an invalid body
+        invalid_bodies = [
+            '',
+            [],
+            {},
+            {'code': 1},
+            {'code':['1']},
+            {'code': 'hello world'},
+            {'code': 'hello world', 'name': 1},
+            {'code': 'hello world', 'name': 'program 1'},
+            {'code': 'hello world', 'name': 'program 1', 'level': '1'},
+            {'code': 'hello world', 'name': 'program 1', 'level': 1, 'adventure_name': 1},
+        ]
+
+        for invalid_body in invalid_bodies:
+            # THEN receive an invalid response code from the server
+            self.post_data('programs/share', invalid_body, expect_http_code=400)
+
+        # WHEN sharing a program without being logged in
+        # THEN receive a forbidden response code from the server
+        self.post_data('programs/share', {'id': '123456', 'public': True}, expect_http_code=403, no_cookie=True)
+
+        # WHEN sharing a program that does not exist
+        # THEN receive a not found response code from the server
+        self.post_data('programs/share', {'id': '123456', 'public': True}, expect_http_code=404)
+
+    def test_valid_make_program_public(self):
+        # GIVEN a logged in user with at least one program
+        self.given_user_is_logged_in()
+        program = {'code': 'hello world', 'name': 'program 1', 'level': 1}
+        program_id = self.post_data('programs', program)['id']
+
+        # WHEN making a program public
+        # THEN receive an OK response code from the server
+        self.post_data('programs/share', {'id': program_id, 'public': True})
+
+        saved_programs = self.get_data('programs_list')['programs']
+        for program in saved_programs:
+            if program['id'] != program_id:
+                continue
+            # THEN the program must have its `public` field enabled
+            self.assertEqual(program['public'], 1)
+
+        # GIVEN another user
+        self.given_fresh_user_is_logged_in()
+        # WHEN requesting a public program
+        # THEN receive an OK response code from the server
+        self.get_data('hedy/1/' + program_id)
+
+    def test_valid_make_program_private(self):
+        # GIVEN a logged in user with at least one public program
+        self.given_user_is_logged_in()
+        program = {'code': 'hello world', 'name': 'program 1', 'level': 1}
+        program_id = self.post_data('programs', program)['id']
+        self.post_data('programs/share', {'id': program_id, 'public': True})
+
+        # WHEN making a program private
+        # THEN receive an OK response code from the server
+        self.post_data('programs/share', {'id': program_id, 'public': False})
+
+        saved_programs = self.get_data('programs_list')['programs']
+        for program in saved_programs:
+            if program['id'] != program_id:
+                continue
+            # THEN the program must have no `public` field
+            self.assertNotIn('public', program)
+
+        # GIVEN another user
+        self.given_fresh_user_is_logged_in()
+        # WHEN requesting a public program
+        # THEN receive a not found response code from the server
+        self.get_data('hedy/1/' + program_id, expect_http_code=404)
+
+    def test_invalid_delete_program(self):
+        # GIVEN a logged in user with at least one program
+        self.given_user_is_logged_in()
+        program = {'code': 'hello world', 'name': 'program 1', 'level': 1}
+        self.post_data('programs', program)['id']
+        program_id = '123456'
+
+        # WHEN deleting a program that does not exist
+        # THEN receive a not ound response code from the server
+        self.get_data('programs/delete/' + program_id, expect_http_code=404)
+
+    def test_valid_delete_program(self):
+        # GIVEN a logged in user with at least one program
+        self.given_user_is_logged_in()
+        program = {'code': 'hello world', 'name': 'program 1', 'level': 1}
+        program_id = self.post_data('programs', program)['id']
+
+        # WHEN deleting a program
+        # THEN receive an OK response code from the server
+        headers = self.get_data('programs/delete/' + program_id, expect_http_code=302, return_headers=True)
+        # THEN verify that the header has a `location` header pointing to `/programs`
+        self.assertEqual(headers['location'], HOST + 'programs')
+
+        saved_programs = self.get_data('programs_list')['programs']
+        for program in saved_programs:
+            # THEN the program should not be any longer in the list of programs
+            self.assertNotEqual(program['id'], program_id)
+
+    def test_destroy_account_with_programs(self):
+        # GIVEN a logged in user with at least one program
+        self.given_user_is_logged_in()
+        program = {'code': 'hello world', 'name': 'program 1', 'level': 1}
+        program_id = self.post_data('programs', program)['id']
+
+        # WHEN deleting the user account
+        # THEN receive an OK response code from the server
+        self.destroy_current_user()
+
+
+class TestClasses(AuthHelper):
+    def test_invalid_create_class(self):
+        # GIVEN a new user
+        # (we create a new user to ensure that the user has no teacher permissions yet)
+        self.given_fresh_user_is_logged_in()
+
+        # WHEN creating a class without teacher permissions
+        # THEN receive a forbidden response code from the server
+        self.post_data('class', {'name': 'class1'}, expect_http_code=403)
+
+        # WHEN marking the user as teacher
+        self.make_current_user_teacher()
+
+        # GIVEN a user with teacher permissions
+
+        # WHEN attempting to create a class with an invalid body
+        invalid_bodies = [
+            '',
+            [],
+            {},
+            {'name': 1},
+            {'name': ['foobar']}
+        ]
+
+        for invalid_body in invalid_bodies:
+            # THEN receive an invalid response code from the server
+            self.post_data('class', invalid_body, expect_http_code=400)
+
+    def test_create_class(self):
+        # GIVEN a user with teacher permissions
+        # (we create a new user to ensure that the user has no classes yet)
+        self.given_teacher_is_logged_in()
+
+        # WHEN retrieving the list of classes
+        class_list = self.get_data('classes')
+
+        # THEN receive a body containing an empty list
+        self.assertIsInstance(class_list, list)
+        self.assertEqual(len(class_list), 0)
+
+        # WHEN creating a class
+        # THEN receive an OK response code with the server
+        self.post_data('class', {'name': 'class1'})
+
+        # GIVEN a class already saved
+        # WHEN retrieving the list of classes
+        class_list = self.get_data('classes')
+        # THEN receive a body containing an list with one element
+        self.assertEqual(len(class_list), 1)
+
+        # THEN validate the fields of the class
+        Class = class_list[0]
+        self.assertIsInstance(Class, dict)
+        self.assertIsInstance(Class['id'], str)
+        self.assertIsInstance(Class['date'], int)
+        self.assertIsInstance(Class['link'], str)
+        self.assertEqual(Class['name'], 'class1')
+        self.assertIsInstance(Class['students'], list)
+        self.assertEqual(len(Class['students']), 0)
+        self.assertEqual(Class['teacher'], self.username)
+
+        # WHEN retrieving the class
+        # THEN receive an OK response code from the server
+        Class = self.get_data('for-teachers/class/' + Class['id'])
+
+        # THEN validate the fields of the class
+        self.assertIsInstance(Class, dict)
+        self.assertIsInstance(Class['id'], str)
+        self.assertIsInstance(Class['link'], str)
+        self.assertEqual(Class['name'], 'class1')
+        self.assertIsInstance(Class['students'], list)
+        self.assertEqual(len(Class['students']), 0)
+
+    def test_invalid_update_class(self):
+        # GIVEN a user with teacher permissions and a class
+        self.given_teacher_is_logged_in()
+        self.post_data('class', {'name': 'class1'})
+        Class = self.get_data('classes') [0]
+
+        # WHEN attempting to update a class with no cookie
+        # THEN receive a forbidden status code from the server
+        self.post_data('class/' + Class['id'], {'name': 'class2'}, expect_http_code=403, put_data=True, no_cookie=True)
+
+        # WHEN attempting to update a class that does not exist
+        # THEN receive a not found status code from the server
+        self.post_data('class/foo', {'name': 'class2'}, expect_http_code=404, put_data=True)
+
+        # WHEN attempting to update a class with an invalid body
+        invalid_bodies = [
+            '',
+            [],
+            {},
+            {'name': 1},
+            {'name': ['foobar']}
+        ]
+
+        for invalid_body in invalid_bodies:
+            # THEN receive an invalid response code from the server
+            self.post_data('class/' + Class['id'], invalid_body, expect_http_code=400, put_data=True)
+
+    def test_update_class(self):
+        # GIVEN a user with teacher permissions and a class
+        self.given_teacher_is_logged_in()
+        self.post_data('class', {'name': 'class1'})
+        Class = self.get_data('classes') [0]
+
+        # WHEN attempting to update a class
+        # THEN receive an OK status code from the server
+        self.post_data('class/' + Class['id'], {'name': 'class2'}, put_data=True)
+
+        # WHEN retrieving the class
+        # THEN receive an OK response code from the server
+        Class = self.get_data('for-teachers/class/' + Class['id'])
+
+        # THEN the name of the class should be updated
+        self.assertEqual(Class['name'], 'class2')
+
+    def test_join_class(self):
+        # GIVEN a teacher
+        self.given_teacher_is_logged_in()
+
+        # GIVEN a class
+        self.post_data('class', {'name': 'class1'})
+        Class = self.get_data('classes') [0]
+
+        # GIVEN a student (user without teacher permissions)
+        self.given_fresh_user_is_logged_in()
+        student = self.user
+
+        # WHEN attempting to join a class without being logged in
+        # THEN receive a forbidden status code from the server
+        self.get_data('class/' + Class['id'] + '/join/' + Class['link'], no_cookie=True, expect_http_code=403)
+
+        # WHEN retrieving the short link of a class
+        # THEN receive a redirect to `class/ID/join/LINK`
+        body = self.get_data('hedy/l/' + Class['link'], expect_http_code=302)
+        if not re.search(HOST + 'class/' + Class['id'] + '/prejoin/' + Class['link'], body):
+            raise Exception('Invalid or missing redirect link')
+
+        # WHEN joining a class
+        # THEN receive a redirect to `class/ID/join/LINK`
+        body = self.get_data('class/' + Class['id'] + '/join/' + Class['link'], expect_http_code=302)
+        if not re.search(HOST + 'my-profile', body):
+            raise Exception('Invalid redirect')
+
+        # WHEN joining a class again (idempotent call)
+        # THEN receive a redirect to `class/ID/join/LINK`
+        body = self.get_data('class/' + Class['id'] + '/join/' + Class['link'], expect_http_code=302)
+        if not re.search(HOST + 'my-profile', body):
+            raise Exception('Invalid redirect')
+
+        # WHEN getting own profile after joining a class
+        profile = self.get_data('profile')
+        # THEN verify that the class is there and contains the right fields
+        self.assertIsInstance(profile['student_classes'], list)
+        self.assertEqual(len(profile['student_classes']), 1)
+        student_class = profile['student_classes'][0]
+        self.assertIsInstance(student_class, dict)
+        self.assertEqual(student_class['id'], Class['id'])
+        self.assertEqual(student_class['name'], Class['name'])
+
+    def test_see_students_in_class(self):
+        # GIVEN a teacher (no classes yet)
+        self.given_fresh_teacher_is_logged_in()
+        teacher = self.user
+        # GIVEN a class
+        self.post_data('class', {'name': 'class1'})
+        Class = self.get_data('classes') [0]
+
+        # GIVEN a student (user without teacher permissions) that has joined the class
+        self.given_fresh_user_is_logged_in()
+        student = self.user
+        self.get_data('class/' + Class['id'] + '/join/' + Class['link'], expect_http_code=302)
+
+        # GIVEN the aforementioned teacher
+        self.switch_user(teacher)
+
+        # WHEN retrieving the class with a student in it
+        Class_data = self.get_data('for-teachers/class/' + Class['id'])
+        # THEN the class should contain a student with valid fields
+        self.assertEqual(len(Class_data['students']), 1)
+        class_student = Class_data['students'][0]
+        self.assertEqual(class_student['highest_level'], 0)
+        self.assertEqual(class_student['programs'], 0)
+        self.assertEqual(class_student['latest_shared'], None)
+        self.assertIsInstance(class_student['last_login'], str)
+        self.assertEqual(class_student['username'], student['username'])
+
+        # WHEN retrieving the student's programs
+        # THEN receive an OK response code from the server
+        body = self.get_data('programs?user=' + student['username'], expect_http_code=200)
+
+    def test_see_students_with_programs_in_class(self):
+        # GIVEN a teacher (no classes yet)
+        self.given_fresh_teacher_is_logged_in()
+        teacher = self.user
+        # GIVEN a class
+        self.post_data('class', {'name': 'class1'})
+        Class = self.get_data('classes') [0]
+
+        # GIVEN a student (user without teacher permissions) that has joined the class and has a public program
+        self.given_fresh_user_is_logged_in()
+        student = self.user
+        self.get_data('class/' + Class['id'] + '/join/' + Class['link'], expect_http_code=302)
+        # GIVEN a student with two programs, one public and one private
+        public_program = {'code': 'hello world', 'name': 'program 1', 'level': 1}
+        public_program_id = self.post_data('programs', public_program)['id']
+        self.post_data('programs/share', {'id': public_program_id, 'public': True})
+        private_program = {'code': 'hello world', 'name': 'program 2', 'level': 2}
+        private_program_id = self.post_data('programs', private_program)['id']
+
+        # GIVEN the aforementioned teacher
+        self.switch_user(teacher)
+
+        # WHEN retrieving the class with a student in it
+        Class_data = self.get_data('for-teachers/class/' + Class['id'])
+        # THEN the class should contain a student with valid fields
+        self.assertEqual(len(Class_data['students']), 1)
+
+
+# *** CLEANUP OF USERS CREATED DURING THE TESTS ***
+
+def tearDownModule ():
+    auth_helper = AuthHelper()
+    auth_helper.setUp()
+    for username in USERS.copy():
+        auth_helper.given_specific_user_is_logged_in(username)
+        auth_helper.destroy_current_user()