diff --git a/Cargo.lock b/Cargo.lock
index 4e2532ca2dec31c656bbd16aa63231f040ad0d42..b15e8e775effd89f0cbcec95a1a569c11936ef30 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -279,6 +279,11 @@ dependencies = [
  "synstructure 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
+[[package]]
+name = "doc-comment"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+
 [[package]]
 name = "dtoa"
 version = "0.4.4"
@@ -594,18 +599,15 @@ dependencies = [
 
 [[package]]
 name = "mime"
-version = "0.3.13"
+version = "0.3.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "unicase 2.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
-]
 
 [[package]]
 name = "mime_guess"
 version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 dependencies = [
- "mime 0.3.13 (registry+https://github.com/rust-lang/crates.io-index)",
+ "mime 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)",
  "unicase 2.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
@@ -931,7 +933,7 @@ dependencies = [
  "hyper 0.12.33 (registry+https://github.com/rust-lang/crates.io-index)",
  "hyper-rustls 0.16.1 (registry+https://github.com/rust-lang/crates.io-index)",
  "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)",
- "mime 0.3.13 (registry+https://github.com/rust-lang/crates.io-index)",
+ "mime 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)",
  "mime_guess 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
  "rustls 0.15.2 (registry+https://github.com/rust-lang/crates.io-index)",
  "serde 1.0.98 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -996,7 +998,7 @@ dependencies = [
 
 [[package]]
 name = "rusty-hektor"
-version = "3.0.0"
+version = "4.0.0"
 dependencies = [
  "base64 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)",
  "calamine 0.15.5 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1013,6 +1015,7 @@ dependencies = [
  "serde 1.0.98 (registry+https://github.com/rust-lang/crates.io-index)",
  "serde_derive 1.0.98 (registry+https://github.com/rust-lang/crates.io-index)",
  "serde_json 1.0.40 (registry+https://github.com/rust-lang/crates.io-index)",
+ "snafu 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)",
  "structopt 0.2.18 (registry+https://github.com/rust-lang/crates.io-index)",
  "sxd-document 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
  "sxd-xpath 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1107,6 +1110,26 @@ name = "smallvec"
 version = "0.6.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 
+[[package]]
+name = "snafu"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "backtrace 0.3.34 (registry+https://github.com/rust-lang/crates.io-index)",
+ "doc-comment 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
+ "snafu-derive 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
+[[package]]
+name = "snafu-derive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)",
+ "quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)",
+ "syn 0.15.44 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
 [[package]]
 name = "spin"
 version = "0.5.1"
@@ -1620,6 +1643,7 @@ dependencies = [
 "checksum ct-logs 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "1b4660f8b07a560a88c02d76286edb9f0d5d64e495d2b0f233186155aa51be1f"
 "checksum derive_more 0.14.1 (registry+https://github.com/rust-lang/crates.io-index)" = "6d944ac6003ed268757ef1ee686753b57efc5fcf0ebe7b64c9fc81e7e32ff839"
 "checksum display_derive 0.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4bba5dcd6d2855639fcf65a9af7bbad0bfb6dbf6fe68fba70bab39a6eb973ef4"
+"checksum doc-comment 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "923dea538cea0aa3025e8685b20d6ee21ef99c4f77e954a30febbaac5ec73a97"
 "checksum dtoa 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)" = "ea57b42383d091c85abcc2706240b94ab2a8fa1fc81c10ff23c4de06e2a90b5e"
 "checksum either 1.5.2 (registry+https://github.com/rust-lang/crates.io-index)" = "5527cfe0d098f36e3f8839852688e63c8fff1c90b2b405aef730615f9a7bcf7b"
 "checksum encoding_rs 0.8.17 (registry+https://github.com/rust-lang/crates.io-index)" = "4155785c79f2f6701f185eb2e6b4caf0555ec03477cb4c70db67b465311620ed"
@@ -1656,7 +1680,7 @@ dependencies = [
 "checksum matches 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08"
 "checksum memchr 2.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "88579771288728879b57485cc7d6b07d648c9f0141eb955f8ab7f9d45394468e"
 "checksum memoffset 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ce6075db033bbbb7ee5a0bbd3a3186bbae616f57fb001c485c7ff77955f8177f"
-"checksum mime 0.3.13 (registry+https://github.com/rust-lang/crates.io-index)" = "3e27ca21f40a310bd06d9031785f4801710d566c184a6e15bad4f1d9b65f9425"
+"checksum mime 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)" = "dd1d63acd1b78403cc0c325605908475dd9b9a3acbf65ed8bcab97e27014afcf"
 "checksum mime_guess 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "1a0ed03949aef72dbdf3116a383d7b38b4768e6f960528cd6a6044aa9ed68599"
 "checksum miniz_oxide 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7108aff85b876d06f22503dcce091e29f76733b2bfdd91eebce81f5e68203a10"
 "checksum miniz_oxide_c_api 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "6c675792957b0d19933816c4e1d56663c341dd9bfa31cb2140ff2267c1d8ecf4"
@@ -1711,6 +1735,8 @@ dependencies = [
 "checksum serde_urlencoded 0.5.5 (registry+https://github.com/rust-lang/crates.io-index)" = "642dd69105886af2efd227f75a520ec9b44a820d65bc133a9131f7d229fd165a"
 "checksum slab 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8"
 "checksum smallvec 0.6.10 (registry+https://github.com/rust-lang/crates.io-index)" = "ab606a9c5e214920bb66c458cd7be8ef094f813f20fe77a54cc7dbfff220d4b7"
+"checksum snafu 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "9d0bf93d08d6a44363b47d737f1f5bebbf5e6a1eaaa3d4c128ceeaca6b718292"
+"checksum snafu-derive 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "624e94bd38e471f67883b467711e7a7ad7dbe284f5fb7e661dc8a671fc5b26a0"
 "checksum spin 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "cbdb51a221842709c2dd65b62ad4b78289fc3e706a02c17a26104528b6aa7837"
 "checksum stable_deref_trait 1.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "dba1a27d3efae4351c8051072d619e3ade2820635c3958d826bfea39d59b54c8"
 "checksum string 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d24114bfcceb867ca7f71a0d3fe45d45619ec47a6fbfa98cb14e14250bfa5d6d"
diff --git a/Cargo.toml b/Cargo.toml
index e90e0c28a683e618f961f75ec124914fd763c51e..44d321f6eb5e1a11af4d2a0b76c7ba0e3b6c56a1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "rusty-hektor"
-version = "3.0.0"
+version = "4.0.0"
 authors = ["robinwilliam.hundt <robinwilliam.hundt@stud.uni-goettingen.de>"]
 license = "MIT OR Apache-2.0"
 description = "A tool to convert ILIAS exam output"
@@ -32,3 +32,4 @@ reqwest = {version = "0.9.11", default-features = false, features = ["rustls-tls
 chrono = {version = "0.4.6", features = ["serde"]}
 semver = {version = "0.9.0", features = ["serde"]}
 display_derive = "0.0.0"
+snafu = "0.5.0"
diff --git a/README.md b/README.md
index 01ecd98913d8a465792ee3117ae4f21b688ddddb..c81ca2d9ec0690b3de209a2af0f092c7bf02f065 100644
--- a/README.md
+++ b/README.md
@@ -16,22 +16,18 @@ View the help message:
 rusty-hektor -h 
 ```
 
-**NOTE**: The option to convert a `.xls` file has been deprecated and will probably be removed in the future.
-
-Convert an ILIAS `.xls` or `.zip` containing `.xml` import into `.json`:
+Convert an ILIAS `.zip` (can be generated on ILIAS for an exam under `Export -> Erstelle Exportdatei (inkl. Teilnehmerergebnisse)`) containing `.xml` into `.json`:
 ```
-rusty-hektor -o <out_file_path> <path_to_(xls|zip)>
+rusty-hektor -o <out_file_path> <path_to_zip>
 ```
 
-Converting an ILIAS Output into an anonymised json representation, storing the mapping file at `map.csv` (DO NOT LOOSE THIS FILE!).
+Converting an ILIAS Output into an **anonymised** json representation, storing the mapping file at `map.csv` (DO NOT LOOSE THIS FILE!).
 ```
 rusty-hektor -a --map-file map.csv -o <out_file_path> <path_to_(xls|zip)> 
 ```
 
 If `-o` is omitted, the output is printed to stdout.  
 
-If you call the program with the paths to an `.xml` and `.xls` export, both will be read and compared for inconsistencies.
-
 By default, the program parses the free text questions and submissions of the `.xml` input. Should you
 wish to skip those questions simply call the program with the `--skip-text` flag.
 
diff --git a/src/anonymizer.rs b/src/anonymizer.rs
index 0f6e92e695b75b4a48bc04c1d9f7dbab7fc1db40..bd53dcff44b45ab00189a2bb1c6ab7c44a587c73 100644
--- a/src/anonymizer.rs
+++ b/src/anonymizer.rs
@@ -4,7 +4,7 @@ use std::collections::HashSet;
 
 use crate::student::StudentSerializable;
 
-const WORD_LIST: &'static str = include_str!("wordlist/eff_large_wordlist.txt");
+const WORD_LIST: &str = include_str!("wordlist/eff_large_wordlist.txt");
 
 #[derive(Default)]
 pub struct StudentMap {
@@ -20,7 +20,7 @@ struct StudentMapItem {
 
 impl StudentMap {
     pub fn into_csv_str(self) -> String {
-        let mut csv_str = format!("key;previous identifier;fullname\n");
+        let mut csv_str = "key;previous identifier;fullname\n".to_string();
         csv_str.push_str(
             &self
                 .map
diff --git a/src/lib.rs b/src/lib.rs
index 10df75fc7745d52fbe4ef796cbc14dbe92550702..4efa1f5b8b9df5a1ecb1ebc414cf665f7bdc5888 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,11 +1,16 @@
 #[macro_use]
 extern crate serde_derive;
 
+use std::{io, io::Write};
+use std::collections::BTreeSet;
 use std::error::Error;
 use std::fmt::Debug;
 use std::result::Result;
 use std::str::FromStr;
-use std::{io, io::Write};
+
+pub use crate::exam::ExamSerializable;
+use crate::student::StudentSerializable;
+use crate::testrunner::Test;
 
 pub mod anonymizer;
 pub mod errors;
@@ -16,8 +21,8 @@ pub mod parser;
 pub mod student;
 pub mod submission;
 pub mod submission_type;
-
-pub use crate::exam::ExamSerializable;
+pub mod test_output;
+pub mod testrunner;
 
 trait MergeOption<T>
 where
@@ -62,3 +67,14 @@ where
         parsed.unwrap()
     }
 }
+
+
+pub fn run_test(test: Box<dyn Test>, students: &mut [StudentSerializable]) {
+    for student in students {
+        student.submissions = student.submissions.clone().into_iter().map(|mut submission| {
+            let test_output = test.run(&submission);
+            submission.tests.insert(test_output.name.clone(), test_output);
+            submission
+        }).collect()
+    }
+}
\ No newline at end of file
diff --git a/src/main.rs b/src/main.rs
index 1ae4d54a1087e136f54f8b22b1fc76413c929c64..4e754553528e4b7906124faca26859743a35f1a0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,8 +1,9 @@
 use std::error::Error;
+use std::ffi::OsStr;
 use std::fs;
 use std::io::{self, Write};
-use std::path::PathBuf;
 use std::ops::Not;
+use std::path::PathBuf;
 
 use chrono::{DateTime, Utc};
 use env_logger::{Builder, Env};
@@ -10,18 +11,24 @@ use semver::Version;
 use serde_derive::Deserialize;
 use structopt::StructOpt;
 
-use rusty_hektor::anonymizer;
+use ParsedData::*;
+use rusty_hektor::{anonymizer, run_test};
 use rusty_hektor::exam::{Exam, ExamSerializable};
 use rusty_hektor::module::Module;
-use rusty_hektor::parser::{xls_parser::XLSParser, xml_parser::XMLParser};
 use rusty_hektor::parser::{Parser, ParserError};
-use ParsedData::*;
+use rusty_hektor::parser::ipynb_parser::notebook::Notebook;
+use rusty_hektor::parser::xml_parser::XMLParser;
+use rusty_hektor::student::StudentSerializable;
+use rusty_hektor::submission_type::{ProgrammingLang, SubmissionType};
+use rusty_hektor::testrunner::empty_test::EmptyTest;
+use rusty_hektor::testrunner::Test;
 
 /// Parse ILIAS exam export for importing into Grady
 #[derive(Debug, StructOpt)]
 struct Opt {
-    /// Provide either the path to the .xls (DEPRECATED), or zipped xml export or both
-    #[structopt(name = "IN_FILES", required = true, max_values = 2, parse(from_os_str))]
+    /// Provide the path to the zipped xml, in the future conversion and merge of multiple exports
+    /// of the same exam will be supported
+    #[structopt(name = "IN_FILE", required = true, max_values = 1, parse(from_os_str))]
     in_files: Vec<PathBuf>,
 
     /// Where to store the parsed data, prints to stdout if not provided
@@ -40,13 +47,47 @@ struct Opt {
     #[structopt(long = "skip-text")]
     skip_text: bool,
 
+    /// Tests to run on the the submissions, the default (and only available option at
+    /// the moment) is "empty" to check for empty submissions
+    #[structopt(short, name = "TEST", parse(from_os_str), default_value = "empty")]
+    tests: Vec<TestEnum>,
+
     /// Where to store the anonymisation map file, if enabled
-    #[structopt(short, long = "map-file", default_value = "anon.csv")]
+    #[structopt(short, long = "map-file", name="MAP_FILE_PATH", default_value = "anon.csv")]
     map_file_name: PathBuf,
 }
 
+
+#[derive(Clone, Debug)]
+enum TestEnum {
+    Empty
+}
+
+impl TestEnum {
+    fn as_test(&self) -> Box<dyn Test> {
+        match self {
+            Self::Empty => Box::new(EmptyTest {})
+        }
+    }
+}
+
+impl From<&OsStr> for TestEnum {
+    fn from(s: &OsStr) -> Self {
+        match s.to_str().expect("Non UTF-8 string in tests") {
+            "empty" => Self::Empty,
+            _ => panic!("Unable to parse test. Allowed values: empty")
+        }
+    }
+
+//    fn from_str(s: &str) -> Result<Self, Self::Err> {
+//        match s {
+//            "empty" => Ok(Self::Empty),
+//            _ => Err("Unable to parse test. Allowed values: empty")?
+//        }
+//    }
+}
+
 enum ParsedData {
-    XLS(Exam),
     XML(Exam),
 }
 
@@ -73,13 +114,11 @@ fn main() -> Result<(), Box<dyn Error>> {
                 .extension()
                 .expect("You need to provide a file with an .xls or .zip extension");
             if extension == "xls" {
-                if yes_or_no(
-                    "The XLS export conversion has been deprecated in favour \
-                     the more reliable XML export. Exit program?",
-                )? {
-                    std::process::exit(1)
-                }
-                Ok(XLS(XLSParser::parse(path, parse_text)?))
+                println!(
+                    ".xls parsing has been removed due to the instability of the format.\
+                     Use zipped .xml instead."
+                );
+                std::process::exit(1)
             } else if extension == "zip" {
                 Ok(XML(XMLParser::parse(path, parse_text)?))
             } else {
@@ -95,17 +134,10 @@ fn main() -> Result<(), Box<dyn Error>> {
 
     let mut serializable = if parsed_data.len() == 1 {
         match parsed_data.pop().unwrap() {
-            XLS(data) => data.into_serializable()?,
             XML(data) => data.into_serializable()?,
         }
     } else {
-        let (xls_data, xml_data) = match (parsed_data.pop().unwrap(), parsed_data.pop().unwrap()) {
-            (XLS(xls_data), XML(xml_data)) => (xls_data, xml_data),
-            (XML(xml_data), XLS(xls_data)) => (xls_data, xml_data),
-            _ => panic!(),
-        };
-
-        merge_xls_xml_into_serializable(xls_data, xml_data)?
+        unreachable!("test")
     };
 
     let mapping = match opt.anon {
@@ -117,6 +149,11 @@ fn main() -> Result<(), Box<dyn Error>> {
         serializable = interactive_annotate(serializable)?;
     }
 
+    for test in opt.tests {
+        let test = test.as_test();
+        run_test(test, &mut serializable.students);
+    }
+
     let json = serde_json::to_string_pretty(&serializable)?;
     match opt.out_file {
         Some(path) => fs::write(path, json)?,
@@ -138,15 +175,45 @@ fn interactive_annotate(mut data: ExamSerializable) -> Result<ExamSerializable,
     data.module = Some(Module::from_interactive()?);
     data.submission_types = data
         .submission_types
+        .clone()
         .into_iter()
         .map(|mut sub_type| {
             sub_type.interactive_annotate()?;
+            if sub_type
+                .programming_language
+                .as_ref()
+                .expect("Programming lan must not be None after annote")
+                == &ProgrammingLang::ipynb
+            {
+                for student in data.students.iter_mut() {
+                    render_student_notebooks(student, &sub_type)?;
+                }
+            }
             Ok(sub_type)
         })
         .collect::<Result<_, Box<dyn Error>>>()?;
     Ok(data)
 }
 
+fn render_student_notebooks(
+    student: &mut StudentSerializable,
+    sub_type: &SubmissionType,
+) -> Result<(), Box<dyn Error>> {
+    let rendered_submissions = student
+        .submissions
+        .clone()
+        .into_iter()
+        .map(|mut submission| {
+            if submission.r#type == sub_type.name {
+                submission.render_code::<Notebook>()?;
+            }
+            Ok(submission)
+        })
+        .collect::<Result<_, Box<dyn Error>>>()?;
+    student.submissions = rendered_submissions;
+    Ok(())
+}
+
 #[derive(Deserialize, Debug)]
 struct Release {
     tag_name: Version,
@@ -178,16 +245,6 @@ fn check_for_new_version() -> Result<(), Box<dyn Error>> {
     Ok(())
 }
 
-fn merge_xls_xml_into_serializable(
-    mut xls_data: Exam,
-    mut xml_data: Exam,
-) -> Result<ExamSerializable, Box<dyn Error>> {
-    xml_data.clean_student_identifier_username();
-    xls_data.validate_and_merge_student_information(&mut xml_data)?;
-    let serializable = xls_data.into_serializable()?;
-    Ok(serializable)
-}
-
 fn yes_or_no(prompt: &str) -> io::Result<bool> {
     let mut buffer = String::new();
     let allowed_responses = vec!["y", "Y", "n", "N", ""];
diff --git a/src/parser/ipynb_parser/example.ipynb b/src/parser/ipynb_parser/example.ipynb
new file mode 100644
index 0000000000000000000000000000000000000000..c92bda3a36bc715aa6218028ca192c2e4cdd911f
--- /dev/null
+++ b/src/parser/ipynb_parser/example.ipynb
@@ -0,0 +1,110 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "this is stdout\n",
+      "even mutliple\n"
+     ]
+    },
+    {
+     "data": {
+      "text/plain": [
+       "10"
+      ]
+     },
+     "execution_count": 1,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "print(\"this is stdout\")\n",
+    "print(\"even mutliple\")\n",
+    "5+5"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "'Execution result'"
+      ]
+     },
+     "execution_count": 2,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "\"Execution result\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# a markdown cell!"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [
+    {
+     "ename": "ZeroDivisionError",
+     "evalue": "division by zero",
+     "output_type": "error",
+     "traceback": [
+      "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
+      "\u001b[0;31mZeroDivisionError\u001b[0m                         Traceback (most recent call last)",
+      "\u001b[0;32m<ipython-input-2-0106664d39e8>\u001b[0m in \u001b[0;36m<module>\u001b[0;34m()\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0;36m5\u001b[0m\u001b[0;34m/\u001b[0m\u001b[0;36m0\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m",
+      "\u001b[0;31mZeroDivisionError\u001b[0m: division by zero"
+     ]
+    }
+   ],
+   "source": [
+    "5/0"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3.6",
+   "language": "python",
+   "name": "python3.6"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.6.2"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/src/parser/ipynb_parser/mod.rs b/src/parser/ipynb_parser/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..0ac79e4f05c053130d6fc20b3557c3f44894a07e
--- /dev/null
+++ b/src/parser/ipynb_parser/mod.rs
@@ -0,0 +1 @@
+pub mod notebook;
diff --git a/src/parser/ipynb_parser/notebook.rs b/src/parser/ipynb_parser/notebook.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b7ed3ae8edfc219fe4b974c9622f468381337980
--- /dev/null
+++ b/src/parser/ipynb_parser/notebook.rs
@@ -0,0 +1,320 @@
+use core::fmt;
+use std::collections::BTreeMap;
+use std::error::Error;
+use std::fmt::{Display, Formatter};
+use std::str::FromStr;
+
+use regex::Regex;
+use lazy_static::lazy_static;
+
+/// Media attachments (e.g. inline images), stored as mimebundle keyed by filename.
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+pub struct Attachments(BTreeMap<String, Mimebundle>);
+
+/// A mime-type keyed dictionary of data
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+pub struct Mimebundle(BTreeMap<String, MultilineString>);
+
+impl Display for Mimebundle {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        if let Some(content) = self.0.get("text/plain") {
+            write!(f, "{}", content)
+        } else {
+            write!(
+                f,
+                "Unsupported display type, please refer to source notebook."
+            )
+        }
+    }
+}
+
+/// Contents of the cell, represented as an array of lines.
+pub type Source = MultilineString;
+
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+#[serde(untagged)]
+pub enum MultilineString {
+    SingleString(String),
+    MultiLine(Vec<String>),
+}
+
+impl Display for MultilineString {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        use MultilineString::*;
+
+        match self {
+            SingleString(single) => write!(f, "{}", single),
+            MultiLine(multi) => write!(f, "{}", multi.join("")),
+        }
+    }
+}
+
+impl From<MultilineString> for String {
+    fn from(s: MultilineString) -> Self {
+        match s {
+            MultilineString::SingleString(single) => single,
+            MultilineString::MultiLine(multi) => multi.join("\n"),
+        }
+    }
+}
+
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+pub struct Notebook {
+    /// Array of cells of the current notebook.
+    pub cells: Vec<Cell>,
+    /// Notebook format (major number). Incremented between backwards incompatible changes to the
+    /// notebook format.
+    pub nbformat: i64,
+    /// Notebook format (minor number). Incremented for backward compatible changes to the notebook
+    /// format.
+    pub nbformat_minor: i64,
+}
+
+impl FromStr for Notebook {
+    type Err = NotebookParseError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(serde_json::from_str(s)?)
+    }
+}
+
+impl Display for Notebook {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        for cell in &self.cells {
+            write!(f, "{}", cell)?;
+        }
+        Ok(())
+    }
+}
+
+#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
+#[serde(tag = "cell_type", rename_all = "lowercase")]
+pub enum Cell {
+    Code(CodeCell),
+    Markdown(MarkdownCell),
+    Raw(RawCell),
+}
+
+impl Display for Cell {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Code(cell) => write!(f, "{}", cell),
+            Self::Markdown(cell) => write!(f, "{}", cell),
+            Self::Raw(cell) => write!(f, "{}", cell),
+        }
+    }
+}
+
+/// Notebook code cell.
+#[serde(rename = "code_cell")]
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+pub struct CodeCell {
+    /// Execution, display, or stream outputs.
+    pub outputs: Vec<Output>,
+    pub source: Source,
+    /// The code cell's prompt number. Will be null if the cell has not been run.
+    #[serde(default)]
+    pub execution_count: Option<i64>,
+}
+
+impl Display for CodeCell {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        let exec_count_as_str = self
+            .execution_count
+            .and_then(|count| Some(count.to_string()))
+            .unwrap_or(" ".into());
+        writeln!(f, "# In[{}]:\n", exec_count_as_str)?;
+        writeln!(f, "{}\n", self.source)?;
+
+        writeln!(f, "# Out[{}]:\n", exec_count_as_str)?;
+        for output in &self.outputs {
+            write!(f, "{}", comment_out(output.to_string()))?;
+        }
+        Ok(())
+    }
+}
+
+/// Notebook markdown cell.
+#[serde(rename = "markdown_cell")]
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+pub struct MarkdownCell {
+    pub attachments: Option<Attachments>,
+    pub source: Source,
+}
+
+impl Display for MarkdownCell {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        writeln!(f, "# Markdown:\n{}", comment_out(self.source.to_string()))?;
+        if self.attachments.is_some() {
+            write!(
+                f,
+                "# Cell contains attachments, please refer to source notebook"
+            )?;
+        }
+        Ok(())
+    }
+}
+
+/// Notebook raw nbconvert cell.
+#[serde(rename = "raw_cell")]
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+pub struct RawCell {
+    pub attachments: Option<Attachments>,
+    pub source: Source,
+}
+
+impl Display for RawCell {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        writeln!(f, "# RawCell:\n{}", comment_out(self.source.to_string()))?;
+        if self.attachments.is_some() {
+            writeln!(
+                f,
+                "# Cell contains attachments, please refer to source notebook"
+            )?;
+        }
+        Ok(())
+    }
+}
+
+#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
+#[serde(tag = "output_type")]
+#[allow(non_camel_case_types)]
+pub enum Output {
+    #[serde(rename = "execute_result")]
+    Execute(ExecuteResult),
+    #[serde(rename = "display_result")]
+    Display(DisplayData),
+    #[serde(rename = "stream")]
+    Stream(Stream),
+    #[serde(rename = "error")]
+    Error(ExecutionError),
+}
+
+impl Display for Output {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Execute(out) => write!(f, "{}", out),
+            Self::Display(out) => write!(f, "{}", out),
+            Self::Stream(out) => write!(f, "{}", out),
+            Self::Error(out) => write!(f, "{}", out),
+        }
+    }
+}
+
+/// Result of executing a code cell.
+#[serde(rename = "execute_result")]
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+pub struct ExecuteResult {
+    pub data: Mimebundle,
+    #[serde(default)]
+    pub execution_count: Option<i64>,
+}
+
+impl Display for ExecuteResult {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(f, "{}", self.data)
+    }
+}
+
+/// Data displayed as a result of code cell execution.
+#[serde(rename = "display_data")]
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+pub struct DisplayData {
+    pub data: Mimebundle,
+}
+
+impl Display for DisplayData {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(f, "{}", self.data)
+    }
+}
+
+/// Stream output from a code cell.
+#[serde(rename = "stream")]
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+pub struct Stream {
+    /// The name of the stream (stdout, stderr).
+    pub name: String,
+    /// The stream's text output, represented as an array of strings.
+    pub text: MultilineString,
+}
+
+impl Display for Stream {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(f, "{}", self.text)
+    }
+}
+
+lazy_static! {
+    static ref STRIP_ASCII_COLOR: Regex = Regex::new("\u{001B}\\[[;\\d]*[ -/]*[@-~]").unwrap();
+}
+
+/// Output of an error that occurred during code cell execution.
+#[serde(rename = "error")]
+#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
+pub struct ExecutionError {
+    /// The name of the error.
+    pub ename: String,
+    /// The value, or message, of the error.
+    pub evalue: String,
+    /// The error's traceback, represented as an array of strings.
+    pub traceback: Vec<String>,
+}
+
+impl Display for ExecutionError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        writeln!(f, "ERROR: {} {}\n", self.ename, self.evalue)?;
+        //TODO strip colouring from traceback
+        let traceback = self.traceback.join("\n");
+        let traceback = STRIP_ASCII_COLOR.replace_all(&traceback, "");
+        writeln!(f, "Traceback:\n{}", traceback)
+    }
+}
+
+fn comment_out(input: impl AsRef<str>) -> String {
+    input
+        .as_ref()
+        .split("\n")
+        .map(|line| format!("# {}\n", line))
+        .collect()
+}
+
+#[derive(Debug)]
+pub struct NotebookParseError {
+    source: serde_json::error::Error,
+}
+
+impl Error for NotebookParseError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
+        Some(&self.source)
+    }
+}
+
+impl fmt::Display for NotebookParseError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> {
+        writeln!(
+            f,
+            "Failed to parse Notebook. Source:\n{}",
+            self.source().unwrap()
+        )
+    }
+}
+
+impl From<serde_json::Error> for NotebookParseError {
+    fn from(err: serde_json::Error) -> Self {
+        Self { source: err }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::fs::File;
+    use std::io::BufReader;
+
+    #[test]
+    fn can_parse_example_notebook() {
+        let reader = BufReader::new(File::open("src/parser/ipynb_parser/example.ipynb").unwrap());
+        let data: Notebook = serde_json::from_reader(reader).unwrap();
+    }
+}
diff --git a/src/parser/mod.rs b/src/parser/mod.rs
index e8ba93630fb51126dfdaedae3ce67b77228e735f..ee2a313e52a3db071a12b7eb8d066d92ad9e4624 100644
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -3,11 +3,9 @@ use std::{error::Error, fmt, io, path::Path, result};
 
 use crate::exam::Exam;
 
-pub mod xls_parser;
+pub mod ipynb_parser;
 pub mod xml_parser;
 
-type Result<T> = std::result::Result<T, ParserError>;
-
 pub trait Parser {
     fn parse<'a>(path: &Path, parse_text_questions: bool) -> result::Result<Exam, Box<dyn Error>>;
 }
diff --git a/src/parser/xls_parser.rs b/src/parser/xls_parser.rs
deleted file mode 100644
index dbf7965ce0f26a3147d0bc0f8b6777c13bad64ed..0000000000000000000000000000000000000000
--- a/src/parser/xls_parser.rs
+++ /dev/null
@@ -1,233 +0,0 @@
-use std::{
-    collections::{BTreeMap, BTreeSet},
-    error::Error,
-    iter,
-    path::Path,
-};
-
-use calamine::{open_workbook, DataType, Range, Reader, Xls};
-
-use lazy_static::lazy_static;
-use log::warn;
-use regex::Regex;
-use url::percent_encoding::percent_decode;
-
-use crate::{
-    exam::Exam,
-    parser::{self, Parser, ParserError},
-    student::Student,
-    submission::Submission,
-    submission_type::SubmissionType,
-};
-
-pub struct XLSParser {}
-
-impl XLSParser {}
-
-const OVERVIEW_SHEET_NAME: &'static str = "Testergebnisse";
-const SUBMISSION_IN_NEXT_ROW_MARKER: &'static str = "Quellcode Frage";
-
-impl Parser for XLSParser {
-    fn parse(path: &Path, parse_text_questions: bool) -> Result<Exam, Box<dyn Error>> {
-        if parse_text_questions {
-            Err(ParserError::new(
-                "XLS Parser is deprecated and doesn't support parsing of text questions",
-            ))?
-        }
-        let mut workbook: Xls<_> = match open_workbook(path) {
-            Ok(workbook) => workbook,
-            Err(xls_err) => Err(ParserError::from(xls_err))?,
-        };
-        let names: Vec<String> = workbook
-            .sheet_names()
-            .iter()
-            .filter(|s| s.as_str() != OVERVIEW_SHEET_NAME)
-            .map(|s| s.to_owned())
-            .collect();
-
-        let ws_range = workbook
-            .worksheet_range(OVERVIEW_SHEET_NAME)
-            .expect("Unable to read overview sheet")
-            .expect("Unable to read overview sheet");
-        let mut students = parse_test_results(ws_range)?;
-        for name in names {
-            let student = students.get_mut(&name).ok_or(ParserError::new(format!(
-                "{} occurs as sheet name but has no test results",
-                name
-            )))?;
-            let student_range =
-                match workbook
-                    .worksheet_range(name.as_str())
-                    .ok_or(ParserError::new(format!(
-                        "Unable to open sheet for {}",
-                        name
-                    )))? {
-                    Ok(range) => range,
-                    Err(xls_err) => Err(ParserError::from(xls_err))?,
-                };
-            student.merge(&mut parse_student_sheet(student_range)?)?;
-        }
-
-        let submission_types = extract_submission_types(students.values().collect())?;
-        let exam = Exam {
-            module: None,
-            students,
-            submission_types,
-        };
-        Ok(exam)
-    }
-}
-
-fn extract_submission_types(students: Vec<&Student>) -> parser::Result<BTreeSet<SubmissionType>> {
-    let mut submission_types: BTreeSet<String> = BTreeSet::new();
-
-    for stud in students.iter() {
-        let sub_types_for_stud: BTreeSet<String> = stud
-            .submissions
-            .as_ref()
-            .expect(&format!("{:?} seems to have no submissions", stud))
-            .iter()
-            .map(|sub| sub.r#type.clone())
-            .collect();
-        if !submission_types.is_empty() && submission_types != sub_types_for_stud {
-            return Err(ParserError::new(format!(
-                "Student {} has differing submission types: {:?} != {:?}",
-                stud.fullname
-                    .as_ref()
-                    .unwrap_or(&"Error: No Name".to_owned()),
-                submission_types,
-                sub_types_for_stud
-            )));
-        } else if submission_types.is_empty() {
-            submission_types = sub_types_for_stud;
-        }
-    }
-    Ok(submission_types
-        .into_iter()
-        .map(|type_name| SubmissionType::new(type_name))
-        .collect())
-}
-
-/// Parses the first sheet of the xls file containing names and matrikelNo's
-fn parse_test_results(data: Range<DataType>) -> parser::Result<BTreeMap<String, Student>> {
-    let mut students = BTreeMap::new();
-    for row in data.rows().skip(1) {
-        let fullname = match &row[0] {
-            DataType::String(s) => s.to_owned(),
-            ty => {
-                return Err(ParserError::new(format!(
-                    "Found Type {} in column 0, expected String",
-                    ty
-                )));
-            }
-        };
-        let identifier = match &row[1] {
-            DataType::String(s) => parse_mat_no(s),
-            ty => {
-                return Err(ParserError::new(format!(
-                    "Found Type {} in column 1, expected String",
-                    ty
-                )));
-            }
-        };
-        let username: Option<String> = Some(
-            fullname
-                .chars()
-                .filter(|c| c.is_uppercase())
-                .chain(identifier.clone().chars())
-                .collect(),
-        );
-        students.insert(
-            fullname.clone(),
-            Student {
-                fullname: Some(fullname),
-                identifier: Some(identifier),
-                username,
-                ..Student::default()
-            },
-        );
-    }
-    Ok(students)
-}
-
-fn parse_student_sheet(data: Range<DataType>) -> parser::Result<Student> {
-    let mut student = Student {
-        submissions: Some(BTreeSet::new()),
-        ..Student::default()
-    };
-
-    for (top_row, bot_row) in data.rows().zip(
-        data.rows()
-            .skip(1)
-            .map(|row| Some(row))
-            .chain(iter::repeat::<Option<&[DataType]>>(None)),
-    ) {
-        let submission = match &top_row[0] {
-            DataType::String(s) => match s.as_str() {
-                SUBMISSION_IN_NEXT_ROW_MARKER => parse_submission_in_sheet(top_row, bot_row)?,
-                _ => continue,
-            },
-            _ => continue,
-        };
-        match student.submissions {
-            Some(ref mut set) => set.insert(submission),
-            None => unreachable!(),
-        };
-    }
-    Ok(student)
-}
-
-fn parse_submission_in_sheet(
-    top_row: &[DataType],
-    bot_row: Option<&[DataType]>,
-) -> parser::Result<Submission> {
-    let submission_type = match &top_row[1] {
-        DataType::String(s) => s.clone(),
-        ty => {
-            return Err(ParserError::new(format!(
-                "Found type {:?} instead of String for submission type name",
-                ty
-            )));
-        }
-    };
-    let code = match bot_row {
-        Some(row) => match &row[0] {
-            DataType::String(s) => percent_decode(s.as_bytes())
-                .decode_utf8()
-                .expect(".xls file contains invalid utf8")
-                .into_owned(),
-            DataType::Empty => "".to_owned(),
-            ty => {
-                return Err(ParserError::new(format!(
-                    "Found type {:?} instead of String or Empty for submission code",
-                    ty
-                )));
-            }
-        },
-        None => "".to_owned(),
-    };
-    Ok(Submission::new(code, submission_type))
-}
-
-fn parse_mat_no(input: &String) -> String {
-    lazy_static! {
-        static ref RE: Regex = Regex::new(r"^(?P<identifier>\d+)-(\d+)-(\d+)$").unwrap();
-    }
-    let caps = match RE.captures(input) {
-        Some(caps) => caps,
-        None => {
-            warn!("Unable to find matrikel number in second column. Falling back to username");
-            return input.to_owned();
-        }
-    };
-    match caps.name("identifier") {
-        Some(hit) => hit.as_str().to_owned(),
-        None => {
-            warn!(
-                "Did not match matrikel number. Expected format: {}",
-                RE.as_str()
-            );
-            input.to_owned()
-        }
-    }
-}
diff --git a/src/submission.rs b/src/submission.rs
index a6d0c32a4b008598ccc016378de828c306f7f4f5..a84afe93cb1c3803fccec27a86c247d9186a3e78 100644
--- a/src/submission.rs
+++ b/src/submission.rs
@@ -1,26 +1,46 @@
+use serde::export::fmt::Display;
 use std::collections::BTreeMap;
+use std::error::Error;
 use std::hash::{Hash, Hasher};
+use std::str::FromStr;
+use crate::test_output::TestOutput;
 
 #[derive(Debug, Eq, PartialEq, Serialize, Default, Clone, PartialOrd, Ord)]
 pub struct Submission {
     pub code: String,
+    /// This field is populated if the displayed source code differs from the
+    /// source. We use this for the correction of `.ipynb` notebooks which we transform
+    /// into a python script to display but want to keep the original notebook as json
+    pub display_code: Option<String>,
     pub r#type: String,
-    pub tests: BTreeMap<(), ()>,
+    pub tests: BTreeMap<String, TestOutput>,
 }
 
 impl Submission {
     pub fn new(code: String, r#type: String) -> Self {
         Submission {
             code,
+            display_code: None,
             r#type,
             tests: BTreeMap::new(),
         }
     }
+
+    pub fn render_code<T>(&mut self) -> Result<(), Box<dyn Error>>
+    where
+        T: FromStr + Display,
+        <T as FromStr>::Err: Error + 'static,
+    {
+        let intermediate: T = self.code.parse()?;
+        self.display_code = Some(intermediate.to_string());
+        Ok(())
+    }
 }
 
 impl Hash for Submission {
     fn hash<H: Hasher>(&self, state: &mut H) {
         self.code.hash(state);
+        self.display_code.hash(state);
         self.r#type.hash(state);
     }
 }
diff --git a/src/submission_type.rs b/src/submission_type.rs
index e216dd3f2bb689b9dd954b2792a2c8ca0e4f63fc..147f13eb8596433e3604fd5b6dd5478e64939f1b 100644
--- a/src/submission_type.rs
+++ b/src/submission_type.rs
@@ -7,7 +7,7 @@ use crate::input;
 
 use display_derive::Display;
 
-#[derive(Debug, Eq, PartialEq, Hash, Serialize, Default, PartialOrd, Ord)]
+#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Default, PartialOrd, Ord)]
 pub struct SubmissionType {
     pub name: String,
     pub full_score: Option<u32>,
@@ -73,18 +73,19 @@ impl SubmissionType {
 }
 
 #[allow(non_camel_case_types)]
-#[derive(Debug, Serialize, Eq, PartialEq, Hash, PartialOrd, Ord)]
+#[derive(Debug, Clone, Serialize, Eq, PartialEq, Hash, PartialOrd, Ord)]
 pub enum ProgrammingLang {
     c,
     java,
     mipsasm,
     haskell,
+    ipynb,
     plaintext,
 }
 
 #[derive(Debug, Display)]
 #[display(
-    fmt = "Unparseable programming language: {}. Allowed: c, java, mipsasm, haskell, plaintext",
+    fmt = "Unparseable programming language: {}. Allowed: c, java, mipsasm, haskell, ipynb, plaintext",
     input
 )]
 pub struct ParseProgrammingLangError {
@@ -103,6 +104,7 @@ impl FromStr for ProgrammingLang {
             "mipsasm" => ProgrammingLang::mipsasm,
             "haskell" => ProgrammingLang::haskell,
             "plaintext" => ProgrammingLang::plaintext,
+            "ipynb" => ProgrammingLang::ipynb,
             _ => Err(ParseProgrammingLangError {
                 input: s.to_owned(),
             })?,
diff --git a/src/test_output.rs b/src/test_output.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ead9a43da1055b22bb08c4f8719363a90ba5ecf2
--- /dev/null
+++ b/src/test_output.rs
@@ -0,0 +1,8 @@
+
+
+#[derive(Debug, Eq, PartialEq, Serialize, Default, Clone, PartialOrd, Ord)]
+pub struct TestOutput {
+    pub name: String,
+    pub annotation: String,
+    pub label: String,
+}
diff --git a/src/testrunner/mod.rs b/src/testrunner/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..e9b15aeef172f0409655fd033f6eec4cc1aebd7d
--- /dev/null
+++ b/src/testrunner/mod.rs
@@ -0,0 +1,42 @@
+use crate::submission::Submission;
+use crate::test_output::TestOutput;
+
+pub trait Test {
+    fn run(&self, submission: &Submission) -> TestOutput;
+}
+
+
+pub mod empty_test {
+    use crate::testrunner::Test;
+    use crate::submission::Submission;
+    use crate::test_output::TestOutput;
+
+    enum Labels {
+        Empty,
+        NotEmpty
+    }
+
+    impl Labels {
+        fn as_str(&self) -> &'static str {
+            match self {
+                Self::Empty => "EMPTY",
+                Self::NotEmpty => "NOT_EMPTY"
+            }
+        }
+    }
+    pub struct EmptyTest {}
+
+    impl Test for EmptyTest {
+        fn run(&self, submission: &Submission) -> TestOutput {
+            let label = match submission.code.trim().len() {
+                0 => Labels::Empty,
+                _ => Labels::NotEmpty
+            };
+            TestOutput {
+                name: "EmptyTest".to_string(),
+                annotation: "".to_string(),
+                label: label.as_str().to_string()
+            }
+        }
+    }
+}
diff --git a/tests/test_export.xls b/tests/test_export.xls
deleted file mode 100644
index f8f5266cb46ba5c1f15dc33d90a3ae3147f2e9b0..0000000000000000000000000000000000000000
Binary files a/tests/test_export.xls and /dev/null differ
diff --git a/tests/test_xls_parser.rs b/tests/test_xls_parser.rs
deleted file mode 100644
index 8fdf846f9bbb7d185af8fdfc633e4c6a4db49107..0000000000000000000000000000000000000000
--- a/tests/test_xls_parser.rs
+++ /dev/null
@@ -1,180 +0,0 @@
-use rusty_hektor::parser::{xls_parser::XLSParser, Parser};
-use rusty_hektor::student::StudentSerializable;
-use std::collections::BTreeSet;
-use std::collections::HashSet;
-use std::error::Error;
-use std::path::Path;
-
-#[test]
-fn can_parse_xls_file() -> Result<(), Box<Error>> {
-    let parsed = XLSParser::parse(Path::new("tests/test_export.xls"), false)?;
-    let serializable = parsed.into_serializable()?;
-
-    assert_eq!(3, serializable.students.len());
-    assert_eq!(6, serializable.submission_types.len());
-    assert_eq!(None, serializable.module);
-
-    Ok(())
-}
-
-#[test]
-fn parsed_xls_contains_coorect_submission_types() -> Result<(), Box<dyn Error>> {
-    let parsed = XLSParser::parse(Path::new("tests/test_export.xls"), false)?;
-    let serializable = parsed.into_serializable()?;
-
-    let submission_type_names: HashSet<String> = serializable
-        .submission_types
-        .into_iter()
-        .map(|st| st.name)
-        .collect();
-
-    let expected: HashSet<String> = [
-        "[a01] Something else",
-        "[a02] Something else entirely",
-        "[a03-1] You wont believe this",
-        "[a03-2] Too hard, gave up",
-        "[a04-1] Ain‘t gonna try",
-        "[a04-2] ……",
-    ]
-    .iter()
-    .map(|s| s.to_string())
-    .collect();
-
-    assert_eq!(submission_type_names, expected);
-
-    Ok(())
-}
-
-#[test]
-fn parsed_xls_contains_correct_students() -> Result<(), Box<dyn Error>> {
-    let parsed = XLSParser::parse(Path::new("tests/test_export.xls"), false)?;
-    let serializable = parsed.into_serializable()?;
-
-    let students: BTreeSet<StudentSerializable> = serializable
-        .students
-        .into_iter()
-        .map(|mut s| {
-            s.submissions = BTreeSet::new();
-            s
-        })
-        .collect();
-
-    let students_expected: BTreeSet<StudentSerializable> = [
-        StudentSerializable {
-            fullname: "Student0".to_owned(),
-            identifier: "20000000".to_owned(),
-            username: "S20000000".to_owned(),
-            ..StudentSerializable::default()
-        },
-        StudentSerializable {
-            fullname: "Student1".to_owned(),
-            identifier: "20000001".to_owned(),
-            username: "S20000001".to_owned(),
-            ..StudentSerializable::default()
-        },
-        StudentSerializable {
-            fullname: "Student2".to_owned(),
-            identifier: "20000002".to_owned(),
-            username: "S20000002".to_owned(),
-            ..StudentSerializable::default()
-        },
-    ]
-    .into_iter()
-    .cloned()
-    .collect();
-
-    assert_eq!(students_expected, students);
-
-    Ok(())
-}
-
-#[test]
-fn correct_mapping_is_generated() -> Result<(), Box<dyn Error>> {
-    use rusty_hektor::anonymizer;
-
-    let parsed = XLSParser::parse(Path::new("tests/test_export.xls"), false)?;
-    let mut serializable = parsed.into_serializable()?;
-
-    let map = anonymizer::replace_students(&mut serializable.students);
-    let map_csv = map.into_csv_str();
-
-    let mut csv_lines = map_csv.split('\n');
-
-    assert_eq!(
-        "key;previous identifier;fullname",
-        csv_lines.next().expect("Mapping file is empty!")
-    );
-
-    let student_map_items: Vec<[&str; 3]> = csv_lines
-        .filter(|line| line.len() != 0)
-        .map(|line| {
-            let mut items = line.split(';');
-            [
-                items.next().expect("Missing key"),
-                items.next().expect("Missing previous identifier"),
-                items.next().expect("Missing fullname"),
-            ]
-        })
-        .collect();
-
-    let find_student_in_map = |num| {
-        student_map_items
-            .iter()
-            .find(|item| {
-                item[1] == format!("2000000{}", num) && item[2] == format!("Student{}", num)
-            })
-            .expect(format!("Unable to find Student{} in mapping", num).as_str())
-    };
-
-    let student0 = find_student_in_map(0);
-    let student1 = find_student_in_map(1);
-    let student2 = find_student_in_map(2);
-
-    let students = serializable.students;
-
-    let find_student_by_key_in_serializable = |key| {
-        students
-            .iter()
-            .find(|stud| stud.identifier == key)
-            .expect("No Student0 in serializable data")
-    };
-
-    for student in [student0, student1, student2].iter() {
-        assert_eq!(
-            6,
-            find_student_by_key_in_serializable(student[0])
-                .submissions
-                .len()
-        )
-    }
-
-    let submission_of_student = |stud_key, sub_type_name| {
-        &find_student_by_key_in_serializable(stud_key)
-            .submissions
-            .iter()
-            .find(|sub| sub.r#type == sub_type_name)
-            .expect(
-                format!(
-                    "Unable to find Submission {} for {}",
-                    sub_type_name, stud_key
-                )
-                .as_str(),
-            )
-            .code
-    };
-
-    assert_eq!(
-        "Student0",
-        submission_of_student(student0[0], "[a04-2] ……")
-    );
-    assert_eq!(
-        "Student1",
-        submission_of_student(student1[0], "[a04-1] Ain‘t gonna try")
-    );
-    assert_eq!(
-        "Student2",
-        submission_of_student(student2[0], "[a03-2] Too hard, gave up")
-    );
-
-    Ok(())
-}
diff --git a/tests/test_xml_parser.rs b/tests/test_xml_parser.rs
index 3980767ef6640be7a271eb5a03b13b8b0d421ddf..c0d9e7ff7be64a1562db5d8f749fbd33945fe5b4 100644
--- a/tests/test_xml_parser.rs
+++ b/tests/test_xml_parser.rs
@@ -2,12 +2,153 @@ use std::error::Error;
 use std::path::Path;
 
 use rusty_hektor::parser::{xml_parser::XMLParser, Parser};
+use std::collections::{HashSet, BTreeSet};
+use rusty_hektor::student::StudentSerializable;
 
 #[test]
-fn can_parse_zipped_xml_data() -> Result<(), Box<Error>> {
+fn can_parse_zipped_xml_data() -> Result<(), Box<dyn Error>> {
     let parsed = XMLParser::parse(Path::new("tests/test.zip"), false)?;
     let serializable = parsed.into_serializable()?;
     assert_eq!(1, serializable.submission_types.len());
     assert_eq!(1, serializable.students.len());
     Ok(())
 }
+
+
+#[test]
+fn parsed_xml_contains_correct_submission_types() -> Result<(), Box<dyn Error>> {
+    let parsed = XMLParser::parse(Path::new("tests/test.zip"), false)?;
+    let serializable = parsed.into_serializable()?;
+
+    let submission_type_names: HashSet<String> = serializable
+        .submission_types
+        .into_iter()
+        .map(|st| st.name)
+        .collect();
+
+    let expected: HashSet<String> = [
+        "Eine Bibliothek für Permutationen (I1-ID: l120mlc005h0)",
+    ]
+        .iter()
+        .map(|s| s.to_string())
+        .collect();
+
+    assert_eq!(submission_type_names, expected);
+
+    Ok(())
+}
+
+#[test]
+fn parsed_xls_contains_correct_students() -> Result<(), Box<dyn Error>> {
+    let parsed = XMLParser::parse(Path::new("tests/test.zip"), false)?;
+    let serializable = parsed.into_serializable()?;
+
+    let students: BTreeSet<StudentSerializable> = serializable
+        .students
+        .into_iter()
+        .map(|mut s| {
+            s.submissions = BTreeSet::new();
+            s
+        })
+        .collect();
+
+    let students_expected: BTreeSet<StudentSerializable> = [
+        StudentSerializable {
+            fullname: "Test, User".to_owned(),
+            identifier: "20000000".to_owned(),
+            username: "TU20000000".to_owned(),
+            ..StudentSerializable::default()
+        },
+    ]
+        .into_iter()
+        .cloned()
+        .collect();
+
+    assert_eq!(students_expected, students);
+
+    Ok(())
+}
+
+#[test]
+fn correct_mapping_is_generated() -> Result<(), Box<dyn Error>> {
+    use rusty_hektor::anonymizer;
+
+    let parsed = XMLParser::parse(Path::new("tests/test.zip"), false)?;
+    let mut serializable = parsed.into_serializable()?;
+
+    let map = anonymizer::replace_students(&mut serializable.students);
+    let map_csv = map.into_csv_str();
+
+    let mut csv_lines = map_csv.split('\n');
+
+    assert_eq!(
+        "key;previous identifier;fullname",
+        csv_lines.next().expect("Mapping file is empty!")
+    );
+
+    let student_map_items: Vec<[&str; 3]> = csv_lines
+        .filter(|line| line.len() != 0)
+        .map(|line| {
+            let mut items = line.split(';');
+            [
+                items.next().expect("Missing key"),
+                items.next().expect("Missing previous identifier"),
+                items.next().expect("Missing fullname"),
+            ]
+        })
+        .collect();
+
+    let find_student_in_map = |num| {
+        student_map_items
+            .iter()
+            .find(|item| {
+                item[1] == format!("2000000{}", num)
+            })
+            .expect(format!("Unable to find Student{} in mapping", num).as_str())
+    };
+
+    let student0 = find_student_in_map(0);
+
+    let students = serializable.students;
+
+    let find_student_by_key_in_serializable = |key| {
+        students
+            .iter()
+            .find(|stud| stud.identifier == key)
+            .expect("No Student in serializable data")
+    };
+
+    for student in [student0, ].iter() {
+        assert_eq!(
+            1,
+            find_student_by_key_in_serializable(student[0])
+                .submissions
+                .len()
+        )
+    }
+
+    let submission_of_student = |stud_key, sub_type_name| {
+        &find_student_by_key_in_serializable(stud_key)
+            .submissions
+            .iter()
+            .find(|sub| sub.r#type == sub_type_name)
+            .expect(
+                format!(
+                    "Unable to find Submission {} for {}",
+                    sub_type_name, stud_key
+                )
+                    .as_str(),
+            )
+            .code
+    };
+
+    assert_eq!(
+        "234;",
+        submission_of_student(
+            student0[0],
+            "Eine Bibliothek für Permutationen (I1-ID: l120mlc005h0)"
+        )
+    );
+
+    Ok(())
+}