diff --git a/.gitignore b/.gitignore
index 2886e55f4ebd761f44add8cccf47fa9ea63f9f85..299c19dad7c879992bab33d3dac6f0eb2fa87bf2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,5 @@ nginx.log
 nginx.pid
 nginx.conf
 uwsgi.sock
+.coverage
+htmlcov/
diff --git a/README.md b/README.md
index 5089659ed206165e1cf778fc05623763021ee1c3..b8d5b45bbcb196c3adafa39fc3f0071f2beec3d3 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,11 @@ Hinweis: diese Variante startet eine lokale Testversion der Website, es sind nic
 
 Alternativ, insbesondere zum Testen der Zugriffsbeschränkungen: Siehe `nginx.example.conf`.
 
+### Unittests
+Tests können mittels `./tests.py` ausgeführt werden.
+
+Coverage Tests können mittels `rm .coverage; python -m coverage run tests.py; python -m coverage html` ausgeführt werden. Dies erstellt einen Ordner `htmlcov` in dem HTML Output liegt.
+
 ### Zum Mitmachen:
 1. Repo für den eigenen User forken, dafür den "Fork-Button" auf der Website verwenden
 2. Sicherstellen, dass der Upstream richtig konfiguriert ist:  
@@ -39,6 +44,7 @@ Optional (wird für einzelne Features benötigt):
 * python-ldap (Login mit Fachschaftsaccount)
 * python-icalendar (SoGo-Kalenderimport für Sitzungsankündigungen)
 * python-mysql-connector (wenn MySQL als Datenbank verwendet werden soll)
+* python-coverage (Für Coverage Tests benötigt)
 
 Kurzform unter Ubuntu:
 `sudo apt install python3 python3-flask sqlite python3-requests python3-lxml python3-ldap3 python3-icalendar python3-mysql.connector`
diff --git a/chapters.py b/chapters.py
index 119d121a510bbc909433f286fb2566eb77d5a0d5..4e1dfa7c8a16bbdc3fa79a5acf62eb67242a8ba7 100644
--- a/chapters.py
+++ b/chapters.py
@@ -11,7 +11,10 @@ def suggest_chapter(lectureid):
 		time= timedelta(hours=x.hour,minutes=x.minute,seconds=x.second).total_seconds()
 		time = int(time)
 	except ValueError:
-		flash('Falsches Zeitformat, "%H:%M:%S" wird erwartet. Z.B. "01:39:42" für eine Kapitel bei Stunde 1, Minute 39, Sekunde 42')
+		if 'ref' in request.values:
+			flash('Falsches Zeitformat, "%H:%M:%S" wird erwartet. Z.B. "01:39:42" für eine Kapitel bei Stunde 1, Minute 39, Sekunde 42')
+			return redirect(request.values['ref'])
+		return 'Wrong time format, "%H:%M:%S" is expected',  400
 		
 	submitter = None
 	if not ismod():
diff --git a/config.py.example b/config.py.example
index fdb02c92b54e19caf93afbd6028a3156713f2fcc..cf7d0ba3b8551363673e6ccccd1221a1b2ab6371 100644
--- a/config.py.example
+++ b/config.py.example
@@ -30,3 +30,4 @@ LDAP_GROUPS = ['fachschaft']
 ERROR_PAGE = 'static/500.html'
 RWTH_IP_RANGES = ['134.130.0.0/16', '137.226.0.0/16', '134.61.0.0/16', '192.35.229.0/24', '2a00:8a60::/32']
 FSMPI_IP_RANGES = ['137.226.35.192/29', '137.226.75.0/27', '137.226.127.32/27', '137.226.231.192/26', '134.130.102.0/26' ]
+DISABLE_SCHEDULER = False
diff --git a/db.py b/db.py
index 04f7fe83ff4ff4540f7d138c819fbce7bc6a9a0f..f64e6a36b6db5dabb604c0bbdaaebf3c20133fdb 100644
--- a/db.py
+++ b/db.py
@@ -23,8 +23,10 @@ if config['DB_ENGINE'] == 'sqlite':
 		db = sqlite3.connect(config['SQLITE_DB'])
 		cur = db.cursor()
 		if config['SQLITE_INIT_SCHEMA']:
+			print('Init db schema')
 			cur.executescript(open(config['DB_SCHEMA']).read())
 		if config['SQLITE_INIT_DATA'] and created:
+			print('Init db data')
 			cur.executescript(open(config['DB_DATA']).read())
 		db.commit()
 		db.close()
diff --git a/legacy.py b/legacy.py
index 0fcfab26c10643448645ac47e5e544caa3901add..14beb0fd934aa6c812ed38505cb5fdf9d0bd8c6c 100644
--- a/legacy.py
+++ b/legacy.py
@@ -10,6 +10,8 @@ def legacy_index():
 			if not courses:
 				return "Not found", 404
 			return redirect(url_for('lecture', course=courses[0]['handle'], id=request.args['lectureid']),code=302)
+		if request.args['view'] == 'faq':
+			return redirect(url_for('faq'),code=302)
 	return None
 
 @app.route('/site/')
diff --git a/scheduler.py b/scheduler.py
index 5bd30e949d32daa6e6e46b804b1b3c4ab7e0c66c..92275055bcde6677fc90520baafcee9b6f17ef5f 100644
--- a/scheduler.py
+++ b/scheduler.py
@@ -27,5 +27,5 @@ def sched_func(delay, priority=0, firstdelay=None, args=[], kargs={}):
 		print("Scheduler: registered {} (frequency 1/{}s, start delay: {}s)".format(func.__name__, delay, firstdelay))
 		return func
 	return wrapper
-
-threading.Thread(target=run_scheduler, daemon=True).start()
+if not app.config['DISABLE_SCHEDULER']:
+	threading.Thread(target=run_scheduler, daemon=True).start()
diff --git a/server.py b/server.py
index d99860cd6ca9e07382ea18c645a36ae6d64a01b3..8ece609dd1667136ed89aacbfa86ace040b85048 100644
--- a/server.py
+++ b/server.py
@@ -25,6 +25,17 @@ if sys.argv[0].endswith('run.py'):
 	config['SQLITE_INIT_DATA'] = True
 	config['DEBUG'] = True
 config.from_pyfile('config.py', silent=True)
+if sys.argv[0].endswith('tests.py'):
+	print('running in test mode')
+	import tempfile
+	# ensure we always use a clean sqlite db for tests
+	config['DB_ENGINE'] = 'sqlite'
+	config['SQLITE_DB'] = tempfile.mktemp(prefix='flasktestingtmp')
+	print('DB File: {}'.format(config['SQLITE_DB']))
+	config['SQLITE_INIT_DATA'] = True
+	config['SQLITE_INIT_SCHEMA'] = True
+	config['DEBUG'] = True
+	config['DISABLE_SCHEDULER'] = True
 if config['DEBUG']:
 	app.jinja_env.auto_reload = True
 
@@ -338,7 +349,7 @@ def login():
 	user = userinfo.get('uid')
 	if not check_mod(user, groups):
 		flash('Login fehlgeschlagen!')
-		return render_template('login.html')
+		return make_response(render_template('login.html'), 403)
 	session['user'] = userinfo
 	dbuser = query('SELECT * FROM users WHERE name = ?', user)
 	if not dbuser:
diff --git a/tests.py b/tests.py
new file mode 100755
index 0000000000000000000000000000000000000000..298f20b2eaa4c9060865726f4cbdf1894c576fa5
--- /dev/null
+++ b/tests.py
@@ -0,0 +1,215 @@
+#!/usr/bin/env python3
+
+import os
+import unittest
+import server
+
+import flask
+from flask import url_for
+
+class VideoTestCase(unittest.TestCase):
+	@classmethod
+	def tearDownClass(cls):
+		os.unlink(server.app.config['SQLITE_DB'])
+	
+	def setUp(self):
+		server.app.testing = True
+		self.app = server.app.test_client()
+
+	def login(self, c):
+		with c.session_transaction() as sess:
+			sess['user'] = {'name': 'videoag', '_csrf_token': 'asd', 'dbid': 72}
+			sess['_csrf_token'] = 'asd'
+
+	def test_index(self):
+		with self.app as c:
+			r = c.get('/')
+			assert r.status_code == 200
+
+			self.login(c)
+			r = c.get('/')
+			assert r.status_code == 200
+	
+	def test_courses(self):
+		with self.app as c:
+			r = c.get('/courses')
+			assert r.status_code == 200
+
+			self.login(c)
+			r = c.get('/courses')
+			assert r.status_code == 200
+
+	def test_course(self):
+		with self.app as c:
+			# normal
+			r = c.get('/15ws-afi')
+			assert r.status_code == 200
+
+			# not listed
+			r = c.get('/15ws-bio')
+			assert r.status_code == 200
+
+			# not visible
+			r = c.get('/15ws-einfprog')
+			assert r.status_code == 404
+
+			self.login(c)
+			r = c.get('/15ws-afi')
+			assert r.status_code == 200
+			r = c.get('/15ws-einfprog')
+			assert r.status_code == 200
+
+	def test_timetable(self):
+		with self.app as c:
+			r = c.get('/internal/timetable')
+			assert r.status_code == 302
+
+			self.login(c)
+			r = c.get('internal/timetable')
+			assert r.status_code == 200
+			r = c.get('internal/timetable?date=2016-W05')
+			assert r.status_code == 200
+			assert 'AfI' in r.data.decode()
+			assert 'Progra' in r.data.decode()
+			assert 'Bio' in r.data.decode()
+	
+	def test_faq(self):
+		r = self.app.get('/faq')
+		assert r.status_code == 200
+
+	def test_ical(self):
+		with self.app as c:
+			r = c.get('/internal/ical/all')
+			assert r.status_code == 401
+
+			self.login(c)
+			r = c.get('/internal/ical/all')
+			assert r.status_code == 200
+			assert 'Progra' in r.data.decode()
+			assert 'Vorlesung' in r.data.decode()
+	
+	def test_sitemap(self):
+		r = self.app.get('/sitemap.xml')
+		assert r.status_code == 200
+	
+	def test_chapters(self):
+		with self.app as c:
+			# wrong time format
+			r = c.post('/internal/newchapter/7011', data={'text':'testchapter A', 'time': 1234})
+			assert r.status_code == 400
+			# should be inserted as id 15
+			r = c.post('/internal/newchapter/7011', data={'text':'testchapter B', 'time': '00:10:00'})
+			assert r.status_code == 200
+			# not yet set visible
+			r = c.get('/internal/chapters/7011')
+			assert r.status_code == 404
+			# other lecture
+			r = c.get('/internal/chapters/7012')
+			assert r.status_code == 404
+
+			self.login(c)
+			r = c.post('/internal/edit', data={"chapters.15.visible":1,"_csrf_token":"asd"})
+			assert r.status_code == 200
+			r = c.get('/internal/chapters/7011')
+			assert 'testchapter B' in r.data.decode() and not 'testchapter A' in r.data.decode()
+
+	def test_search(self):
+		r = self.app.get('/search?q=Malo')
+		assert r.status_code == 200
+		assert 'Mathematische Logik II' in r.data.decode() and '4.1 Der Sequenzenkalkül'  in r.data.decode()
+		r = self.app.get('/search?q=Afi+Stens')
+		assert r.status_code == 200
+		assert 'Analysis für Informatiker' in r.data.decode() and 'Höhere Mathematik I'  in r.data.decode()
+
+	def test_login(self):
+		# test login page
+		r = self.app.get('/internal/login')
+		assert r.status_code == 200
+		# test successfull login
+		with self.app as c:
+			r = c.post('/internal/login', data={'user': 'videoag', 'password': 'videoag', 'ref': '/'})
+			assert flask.session['user']
+			assert r.status_code == 302
+			assert '<a href="/">' in r.data.decode()
+		# test unsuccessfull login
+		with self.app as c:
+			r = c.post('/internal/login', data={'user': 'videoag', 'password': 'asd', 'ref': '/'})
+			assert flask.session['user']
+			assert r.status_code == 403
+			assert 'Login fehlgeschlagen' in r.data.decode()
+
+	def test_logout(self):
+		with self.app as c:
+			with c.session_transaction() as sess:
+				sess['user'] = {'foo': 'bar'}
+			r = c.get('/internal/logout', data={'ref': '/'})
+			assert not flask.session.get('user')
+			assert r.status_code == 302
+			assert '<a href="/">' in r.data.decode()
+	
+	def test_edit(self):
+		with self.app as c:
+			# test auth
+			r = c.get('/internal/new/courses', data={'title': 'Neue Vera14352345nstaltung totalyrandomcrap', 'responsible': 'foo', 'handle': '2r5sQ46z4w3DFCRT3<F4>DG', '_csrf_token': 'asd'})
+			assert r.status_code != 200
+
+			r = c.get('/internal/changelog')
+			assert r.status_code == 302
+
+			# all other tests are done logged in
+			self.login(c)
+
+			# add course
+			r = c.get('/internal/new/courses', data={'title': 'Neue Veranstaltung totalyrandomcrap', 'responsible': 'foo', 'handle': '2r5sQDFCRT3DG', '_csrf_token': 'asd'})
+			assert r.status_code == 200
+			r = self.app.get('/courses')
+			assert r.status_code == 200
+			assert 'Neue Veranstaltung totalyrandomcrap' in r.data.decode() and '2r5sQDFCRT3DG' in r.data.decode()
+
+			# rename lecture
+			r = c.get('internal/edit', data={"lectures.7353.title":"Testtitle","_csrf_token":"asd"})
+			assert r.status_code == 200
+
+			# test if the changelog is written
+			r = c.get('/internal/changelog')
+			assert r.status_code == 200
+			assert 'Testtitle' in r.data.decode() and 'lectures.7353.title' in r.data.decode()
+
+
+	def test_legacyurl(self):
+		with self.app as c:
+			r = self.app.get('/site/feed.php?newcourses')
+			assert r.status_code == 302
+			assert url_for('courses_feed') in r.data.decode()
+			
+			r = self.app.get('/?view=faq')
+			assert r.status_code == 302
+			assert url_for('faq') in r.data.decode()
+
+			r = self.app.get('/site/feed.php?all')
+			assert r.status_code == 302
+			assert url_for('feed') in r.data.decode()
+
+			r = self.app.get('/?course=16ws-progra')
+			assert r.status_code == 302
+			assert url_for('course', handle='16ws-progra') in r.data.decode()
+
+			r = self.app.get('/?view=player&lectureid=7319')
+			assert r.status_code == 302
+			assert url_for('lecture', id='7319', course='16ws-progra') in r.data.decode()
+
+			r = self.app.get('/site/feed.php?16ws-afi')
+			assert r.status_code == 302
+			assert url_for('feed', handle='16ws-afi') in r.data.decode()
+
+			r = self.app.get('/site/feed.php?lecture=7319')
+			assert r.status_code == 302
+			assert False # missing
+
+			r = self.app.get('/site/feed.php?vid=6088')
+			assert r.status_code == 302
+			assert False # missing
+
+if __name__ == '__main__':
+	unittest.main()
+