diff --git a/Makefile b/Makefile
index 3b60ef1fcc6ae7a03155ff09074a4957936e26ee..b20a9c5dab125909f43161fafcd82f4552c4640b 100644
--- a/Makefile
+++ b/Makefile
@@ -33,6 +33,10 @@ test:
 teste2e:
 	cd frontend && yarn build && cp dist/index.html ../core/templates && cd .. && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest --ds=grady.settings $(path); git checkout core/templates/index.html
 
+teste2e-nc:
+	cp frontend/dist/index.html ./core/templates && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest --ds=grady.settings $(path); git checkout core/templates/index.html
+
+
 coverage:
 	DJANGO_SETTINGS_MODULE=grady.settings pytest --cov
 	coverage html
diff --git a/frontend/src/components/SubmissionTests.vue b/frontend/src/components/SubmissionTests.vue
index d782693f54b81eb141b0c4dc9d480c4ec049e103..0012808ed1e6a179152032f59cb18a92c6c17521 100644
--- a/frontend/src/components/SubmissionTests.vue
+++ b/frontend/src/components/SubmissionTests.vue
@@ -1,5 +1,5 @@
 <template>
-    <v-card>
+    <v-card id="submission-tests">
       <v-card-title class="title py-0" v-if="tests.length > 0">
           Tests
         <v-spacer/>
@@ -12,15 +12,15 @@
         No Tests available
       </v-card-title>
       <v-expansion-panel v-if="expanded">
-        <v-expansion-panel-content v-for="item in tests" :key="item.pk">
-          <div slot="header">
-            <v-layout row class="pr-4">
+        <v-expansion-panel-content v-for="item in tests" :key="item.pk" >
+          <div slot="header" name="test-name-label">
+            <v-layout row class="pr-4" >
               {{item.name}}
               <v-spacer/>
               {{item.label}}
             </v-layout>
           </div>
-          <v-card>
+          <v-card name="test-output">
             <v-card-text class="test-output">{{item.annotation}}</v-card-text>
           </v-card>
         </v-expansion-panel-content>
diff --git a/frontend/src/components/SubmissionType.vue b/frontend/src/components/SubmissionType.vue
index 279ba35580c70ecf5f360eadc9ecdaba803e9be5..3554e20de4eee5ef5b19dba92fc67e4605d2a338 100644
--- a/frontend/src/components/SubmissionType.vue
+++ b/frontend/src/components/SubmissionType.vue
@@ -1,6 +1,6 @@
 <template>
   <v-layout column>
-    <v-card>
+    <v-card id="submission-type">
       <v-card-title class="title mb-2">{{ name }} - Full score: {{ fullScore }}</v-card-title>
       <v-expansion-panel expand v-model="expanded">
         <v-expansion-panel-content
diff --git a/frontend/src/components/submission_notes/SubmissionCorrection.vue b/frontend/src/components/submission_notes/SubmissionCorrection.vue
index 88811af851fd3a7fbf9682dad3ee60b6972ee84e..29d973b964d0b802c3551ce8af4b9c25accce76c 100644
--- a/frontend/src/components/submission_notes/SubmissionCorrection.vue
+++ b/frontend/src/components/submission_notes/SubmissionCorrection.vue
@@ -5,8 +5,8 @@
         class="mb-1 elevation-1"
         slot="header"
       />
-      <template slot="table-content">
-        <tr v-for="(code, lineNo) in submission" :key="`${submissionObj.pk}${lineNo}`">
+      <template slot="table-content" id='sub-lines'>
+        <tr v-for="(code, lineNo) in submission" :key="`${submissionObj.pk}${lineNo}`" :id="`sub-line-${lineNo}`">
           <submission-line
             :code="code"
             :line-no="lineNo"
@@ -134,7 +134,8 @@ export default {
         this.$notify({
           title: 'Feedback creation Error!',
           text: err.message,
-          type: 'error'
+          type: 'error',
+          duration: -1
         })
       }).finally(() => {
         this.loading = false
diff --git a/frontend/src/components/submission_notes/base/CommentForm.vue b/frontend/src/components/submission_notes/base/CommentForm.vue
index 32989753fed1e63530dcffbe8ab46f244c0fc123..1e25cbd8d61604da53ac4c8fd62c4ef9dc5c4cb6 100644
--- a/frontend/src/components/submission_notes/base/CommentForm.vue
+++ b/frontend/src/components/submission_notes/base/CommentForm.vue
@@ -13,8 +13,8 @@
       auto-grow
       hide-details
     />
-    <v-btn color="success" @click="submitFeedback"><v-icon>check</v-icon>Submit</v-btn>
-    <v-btn @click="collapseTextField"><v-icon>cancel</v-icon>cancel</v-btn>
+    <v-btn id="submit-comment" color="success" @click="submitFeedback"><v-icon>check</v-icon>Submit</v-btn>
+    <v-btn id="cancel-comment" @click="collapseTextField"><v-icon>cancel</v-icon>cancel</v-btn>
   </div>
 </template>
 
diff --git a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue
index 7e25ecf178dcc9d8d81e0cdf9f7e9fdb19122d98..0005c11b5d9fbbb3ef787bddcaa8892ccbd77d8d 100644
--- a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue
+++ b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue
@@ -3,6 +3,7 @@
     <v-tooltip top v-if="skippable">
       <v-btn
         slot="activator"
+        id="skip-submission"
         outline round color="grey darken-2"
         @click="skipSubmission"
       >Skip</v-btn>
@@ -19,6 +20,7 @@
     <input
       class="score-text-field"
       type="number"
+      id="score-input"
       v-model="score"
       @input="validateScore"
       @change="validateScore"
@@ -26,11 +28,13 @@
     <span>&nbsp;/ {{fullScore}}</span>
     <v-btn
       outline round flat
+      id="score-zero"
       @click="score = 0"
       color="red lighten-1"
       class="score-button">0</v-btn>
     <v-btn
       outline round flat
+      id="score-full"
       @click="score = fullScore"
       color="blue darken-3"
       class="score-button">{{fullScore}}</v-btn>
@@ -45,6 +49,7 @@
       <v-btn
         color="success"
         slot="activator"
+        id="submit-feedback"
         :loading="loading"
         @click="submit"
       >Submit<v-icon>chevron_right</v-icon></v-btn>
diff --git a/frontend/src/components/subscriptions/SubscriptionEnded.vue b/frontend/src/components/subscriptions/SubscriptionEnded.vue
index 7db964e7d1ed0ceb8f83b9dadc77c14ec8886da3..598df9d8edff29e81b5330b509241060965a7d78 100644
--- a/frontend/src/components/subscriptions/SubscriptionEnded.vue
+++ b/frontend/src/components/subscriptions/SubscriptionEnded.vue
@@ -1,5 +1,5 @@
 <template>
-  <v-card class="mx-auto center-page">
+  <v-card class="mx-auto center-page" id="subscription-ended">
     <v-card-title class="title">
       It seems like your subscription has (temporarily) ended.
     </v-card-title>
diff --git a/frontend/src/components/subscriptions/SubscriptionList.vue b/frontend/src/components/subscriptions/SubscriptionList.vue
index 316b1b0e4c8cfe76618b35b84ea262d7a8576c98..fcdf863c553b5d4d94c0be5bbc12be420a8ac58a 100644
--- a/frontend/src/components/subscriptions/SubscriptionList.vue
+++ b/frontend/src/components/subscriptions/SubscriptionList.vue
@@ -21,7 +21,7 @@
         {{item}}
       </v-tab>
       <v-tab-item v-for="(stage, i) in stages" :key="i">
-        <subscriptions-for-stage :stage="stage"/>
+        <subscriptions-for-stage :stage="stage" :id="`stage-${i}`"/>
       </v-tab-item>
     </v-tabs>
   </v-card>
diff --git a/functional_tests/test_feedback_creation.py b/functional_tests/test_feedback_creation.py
new file mode 100644
index 0000000000000000000000000000000000000000..74486392b1adcedbff039447467284de14f1fb19
--- /dev/null
+++ b/functional_tests/test_feedback_creation.py
@@ -0,0 +1,245 @@
+from django.test import LiveServerTestCase
+from selenium import webdriver
+from selenium.webdriver.common.by import By
+from selenium.common.exceptions import StaleElementReferenceException
+from selenium.webdriver.support.ui import WebDriverWait
+from selenium.webdriver.support import expected_conditions as ec
+
+from core.models import UserAccount, Submission, FeedbackComment
+from functional_tests.util import (login, create_browser, reset_browser_after_test,
+                                   subscriptions_loaded_cond)
+from util import factory_boys as fact
+
+
+class UntestedParent:
+    class TestFeedbackCreationGeneric(LiveServerTestCase):
+        browser: webdriver.Firefox = None
+        username = None
+        password = None
+        role = None
+
+        @classmethod
+        def setUpClass(cls):
+            super().setUpClass()
+            cls.browser = create_browser()
+
+        @classmethod
+        def tearDownClass(cls):
+            super().tearDownClass()
+            cls.browser.quit()
+
+        def setUp(self):
+            self.sub_type = fact.SubmissionTypeFactory.create()
+            fact.SubmissionFactory.create_batch(2, type=self.sub_type)
+
+        def tearDown(self):
+            reset_browser_after_test(self.browser, self.live_server_url)
+
+        def _login(self):
+            login(self.browser, self.live_server_url, self.username, self.password)
+
+        def _reconstruct_submission_code(self):
+            sub_table = self.browser.find_element_by_class_name('submission-table')
+            lines = sub_table.find_elements_by_tag_name('tr')
+            line_no_code_pairs = [
+                (line.get_attribute('id'),
+                 # call get_attribute here to get non normalized text
+                 # https://github.com/SeleniumHQ/selenium/issues/2608
+                 line.find_element_by_class_name('code-cell-content').get_attribute('textContent'))
+                for line in lines
+            ]
+            line_no_code_pairs.sort(key=lambda x: x[0])  # sort by ids
+            code_lines = list(zip(*line_no_code_pairs))[1]
+            return '\n'.join(code_lines)
+
+        # stage can be 'initial', 'validate', or 'conflict'
+        def _go_to_subscription(self, stage='initial', sub_type=None):
+            tasks = self.browser.find_element_by_name('subscription-list')
+            WebDriverWait(self.browser, 3).until(subscriptions_loaded_cond(tasks))
+            tab = tasks.find_element_by_xpath(f'//*[contains(text(), "{stage}")]')
+            tab.click()
+            sub_type = sub_type if sub_type is not None else self.sub_type
+
+            sub_type_xpath = f'//*[contains(text(), "{sub_type.name}") ' \
+                f'and not(contains(@class, "inactive"))]'
+            WebDriverWait(self.browser, 3).until(
+                ec.element_to_be_clickable((By.XPATH, sub_type_xpath)),
+                message="SubmissionType not clickable"
+            )
+            sub_type_el = tasks.find_element_by_xpath(sub_type_xpath)
+            sub_type_el.click()
+            WebDriverWait(self.browser, 3).until(ec.url_contains('subscription'))
+
+        def write_comments_on_lines(self, line_comment_tuples):
+            """ line_comment_tuples is an iterable containing tuples of
+                (line_no, comment) where the line number starts at 1
+            """
+
+            sub_table = self.browser.find_element_by_class_name('submission-table')
+            lines = sub_table.find_elements_by_tag_name('tr')
+
+            for (line_no, comment) in line_comment_tuples:
+                line = lines[line_no-1]
+                line.find_element_by_tag_name('button').click()
+                textarea = line.find_element_by_tag_name('textarea')
+                textarea.send_keys(comment)
+                line.find_element_by_id('submit-comment').click()
+
+        def test_student_text_is_correctly_displayed(self):
+            self._login()
+            self._go_to_subscription()
+            code = self._reconstruct_submission_code()
+            # query db for Submission with seen code, throws if not present and test fails
+            Submission.objects.get(text=code)
+
+        def test_submission_type_is_correctly_displayed(self):
+            self._login()
+            self._go_to_subscription()
+            sub_type_el = self.browser.find_element_by_id('submission-type')
+            title = sub_type_el.find_element_by_class_name('title')
+            self.assertEqual(
+                f'{self.sub_type.name} - Full score: {self.sub_type.full_score}',
+                title.text
+            )
+            solution = sub_type_el.find_element_by_class_name('solution-code')
+            self.assertEqual(self.sub_type.solution, solution.get_attribute('textContent'))
+            description = sub_type_el.find_element_by_class_name('type-description')
+            html_el_in_desc = description.find_element_by_tag_name('h1')
+            self.assertEqual('This', html_el_in_desc.text)
+
+        def test_test_output_is_displayed(self):
+            # create a test for every submission
+            test = None
+            for submission in Submission.objects.all():
+                test = fact.TestFactory.create(submission=submission, annotation='This is a test')
+            self._login()
+            self._go_to_subscription()
+            tests = self.browser.find_element_by_id('submission-tests')
+            name_label = tests.find_element_by_name('test-name-label')
+            name_label.click()
+            self.assertIn(test.name, name_label.text)
+            self.assertIn(test.label, name_label.text)
+            test_output = tests.find_element_by_name('test-output')
+            WebDriverWait(self.browser, 3).until(ec.visibility_of(test_output))
+            self.assertEqual(test.annotation, test_output.text)
+
+        def wait_until_code_changes(self, code):
+            def condition(*args):
+                try:
+                    # code might change during the call resulting in the exception
+                    new_code = self._reconstruct_submission_code()
+                except StaleElementReferenceException:
+                    return False
+                return code != new_code
+            return condition
+
+        def test_can_give_max_score(self):
+            self._login()
+            self._go_to_subscription()
+            code = self._reconstruct_submission_code()
+            self.browser.find_element_by_id('score-full').click()
+            submit_btn = self.browser.find_element_by_id('submit-feedback')
+            submit_btn.click()
+            WebDriverWait(self.browser, 3).until(
+                self.wait_until_code_changes(code)
+            )
+            submission_for_code = Submission.objects.get(text=code)
+            self.assertEqual(self.sub_type.full_score, submission_for_code.feedback.score)
+
+        def test_zero_score_without_warning_gives_error(self):
+            self._login()
+            self._go_to_subscription()
+            self.browser.find_element_by_id('score-zero').click()
+            submit_btn = self.browser.find_element_by_id('submit-feedback')
+            submit_btn.click()
+            notification = self.browser.find_element_by_class_name('notification-content')
+            self.assertIn('comment', notification.text)
+
+        def test_can_give_zero_score(self):
+            self._login()
+            self._go_to_subscription()
+            code = self._reconstruct_submission_code()
+            self.browser.find_element_by_id('score-zero').click()
+            self.write_comments_on_lines([(0, 'A comment')])
+            self.browser.find_element_by_id('submit-feedback').click()
+            WebDriverWait(self.browser, 3).until(self.wait_until_code_changes(code))
+            submission_for_code = Submission.objects.get(text=code)
+            self.assertEqual(0, submission_for_code.feedback.score)
+
+        def test_can_give_comments_and_decreased_score(self):
+            self._login()
+            self._go_to_subscription()
+            code = self._reconstruct_submission_code()
+
+            # give half full score
+            score_input = self.browser.find_element_by_id('score-input')
+            score_input.send_keys(self.sub_type.full_score // 2)
+
+            # give feedback on first and last line of submission
+            comment_text = 'This is feedback'
+            self.write_comments_on_lines([
+                (1, comment_text), (0, comment_text)  # 0 corresponds to the last line
+            ])
+
+            submit_btn = self.browser.find_element_by_id('submit-feedback')
+            submit_btn.click()
+            WebDriverWait(self.browser, 3).until(
+                self.wait_until_code_changes(code)
+            )
+            submission_for_code = Submission.objects.get(text=code)
+            self.assertEqual(self.sub_type.full_score // 2, submission_for_code.feedback.score)
+            self.assertEqual(2, submission_for_code.feedback.feedback_lines.count())
+            fst_comment = FeedbackComment.objects.get(
+                of_feedback=submission_for_code.feedback,
+                of_line=1
+            )
+            self.assertEqual(comment_text, fst_comment.text)
+            last_line_of_sub = len(submission_for_code.text.split('\n'))
+            snd_comment = FeedbackComment.objects.get(
+                of_feedback=submission_for_code.feedback,
+                of_line=last_line_of_sub
+            )
+            self.assertEqual(comment_text, snd_comment.text)
+
+        def test_can_skip_submission(self):
+            self._login()
+            self._go_to_subscription()
+            code = self._reconstruct_submission_code()
+            self.browser.find_element_by_id('skip-submission').click()
+            WebDriverWait(self.browser, 3).until(self.wait_until_code_changes(code))
+
+        def test_can_validate_submission(self):
+            self._login()
+            self._go_to_subscription()
+            code = self._reconstruct_submission_code()
+            self.write_comments_on_lines([(0, 'A comment by me')])
+            self.browser.find_element_by_id('score-zero').click()
+            self.browser.find_element_by_id('submit-feedback').click()
+            WebDriverWait(self.browser, 3).until(self.wait_until_code_changes(code))
+            reset_browser_after_test(self.browser, self.live_server_url)  # logs out user
+
+            user_snd = 'tutor_snd'
+            password = 'p'
+            fact.UserAccountFactory(username=user_snd, password=password)
+
+            login(self.browser, self.live_server_url, user_snd, password)
+            self._go_to_subscription(stage='validate')
+            self.write_comments_on_lines([(0, 'I disagree'), (1, 'Full points!')])
+            self.browser.find_element_by_id('score-full').click()
+            self.browser.find_element_by_id('submit-feedback').click()
+            WebDriverWait(self.browser, 5).until(ec.url_contains('subscription/ended'))
+            submission_for_code = Submission.objects.get(text=code)
+            self.assertEqual(self.sub_type.full_score, submission_for_code.feedback.score)
+            self.assertEqual(3, submission_for_code.feedback.feedback_lines.count())
+
+
+class TestFeedbackCreationTutor(UntestedParent.TestFeedbackCreationGeneric):
+    def setUp(self):
+        super().setUp()
+        self.username = 'tutor'
+        self.password = 'p'
+        self.role = UserAccount.TUTOR
+        fact.UserAccountFactory(
+            username=self.username,
+            password=self.password
+        )
diff --git a/functional_tests/test_front_pages.py b/functional_tests/test_front_pages.py
index adcc09757bf23ad1739c9a02bc30c34e74ad4f15..d7a05296f475aaea0804918f9e2a6901034811d9 100644
--- a/functional_tests/test_front_pages.py
+++ b/functional_tests/test_front_pages.py
@@ -4,7 +4,7 @@ from selenium.webdriver.support.ui import WebDriverWait
 
 from core import models
 from core.models import UserAccount
-from functional_tests.util import (login, create_browser,
+from functional_tests.util import (login, create_browser, subscriptions_loaded_cond,
                                    extract_hrefs_hashes, reset_browser_after_test)
 from util import factory_boys as fact
 
@@ -46,15 +46,6 @@ class UntestedParent:
             self.assertEqual('Statistics', title.text)
 
         def test_available_tasks_are_shown(self):
-            # A function that takes the element corresponding to the tasks
-            # component and return a function that can be used as a condition for
-            # WebDriverWait
-            def subscriptions_loaded_cond(tasks_el):
-                def loaded(*args):
-                    sub_links = tasks_el.find_elements_by_tag_name('a')
-                    return any((link != '/home' for link in extract_hrefs_hashes(sub_links)))
-                return loaded
-
             self._login()
             tasks = self.browser.find_element_by_name('subscription-list')
             title = tasks.find_element_by_class_name('v-toolbar__title')
diff --git a/functional_tests/util.py b/functional_tests/util.py
index 912d68a388280088cf181b5dabdf55b2b0f9b0e0..d6ca543483faa91504f23942d6b81f4ff25157be 100644
--- a/functional_tests/util.py
+++ b/functional_tests/util.py
@@ -15,6 +15,7 @@ def create_browser() -> webdriver.Firefox:
     options.headless = bool(os.environ.get('HEADLESS_TESTS', False))
     browser = webdriver.Firefox(options=options)
     browser.implicitly_wait(10)
+    browser.set_window_size(1920, 1080)
     return browser
 
 
@@ -44,3 +45,13 @@ def nth(iterable, n, default=None):
 def extract_hrefs_hashes(web_elements: Sequence[WebElement]):
     return [nth(el.get_attribute('href').split('#'), 1, '')
             for el in web_elements if el.get_attribute('href')]
+
+
+# A function that takes the element corresponding to the tasks
+# component and return a function that can be used as a condition for
+# WebDriverWait
+def subscriptions_loaded_cond(tasks_el):
+    def loaded(*args):
+        sub_links = tasks_el.find_elements_by_tag_name('a')
+        return any((link != '/home' for link in extract_hrefs_hashes(sub_links)))
+    return loaded
diff --git a/util/factory_boys.py b/util/factory_boys.py
index e89dd70a2d671a350dd5b91774cebdb1e89154d7..12db198aff1b7b178afc4913200e262482a8fe0a 100644
--- a/util/factory_boys.py
+++ b/util/factory_boys.py
@@ -21,10 +21,11 @@ class ExamTypeFactory(DjangoModelFactory):
 class SubmissionTypeFactory(DjangoModelFactory):
     class Meta:
         model = models.SubmissionType
-    name = factory.Sequence(lambda n: f"[{n}] Example submission type ")
+    name = factory.Sequence(lambda n: f"[{n}] Example submission type")
     full_score = 15
-    description = '<h1>This</h1> is a <b>description</b> containing html'
-    solution = 'This usually contains commented code'
+    description = factory.Sequence(
+        lambda n: f'Type {n} \n<h1>This</h1> is a description containing html')
+    solution = factory.Sequence(lambda n: f'//This is a solution\n#include<stdio.h>\n\nint main() {{\n\tprintf("Hello World\\n");\n\treturn {n};\n}}')  # noqa
     programming_language = models.SubmissionType.C
 
 
@@ -53,20 +54,14 @@ class TestFactory(DjangoModelFactory):
 
     name = 'EmptyTest'
     label = 'Empty'
-    annotation = 'This is an annotation'
+    annotation = factory.Sequence(lambda n: f'Test: {n} This is an annotation')
 
 
 class SubmissionFactory(DjangoModelFactory):
     class Meta:
         model = models.Submission
 
-    text = """#include<stdio.h>
-
-int main() {
-    printf("Hello World\n");
-    return 0;
-}
-"""
+    text = factory.Sequence(lambda n: f'#include<stdio.h>\n\nint main() {{\n\tprintf("Hello World\\n");\n\treturn {n};\n}}')  # noqa
     type = factory.SubFactory(SubmissionTypeFactory)
     student = factory.SubFactory(StudentInfoFactory)