diff --git a/core/signals.py b/core/signals.py
index 342532e7e96d887a2291b2e5778b68f501d46f19..95cdcc1a46c21bdc27e5af7389418f1f0fba212d 100644
--- a/core/signals.py
+++ b/core/signals.py
@@ -81,4 +81,3 @@ def set_comment_visibility_after_conflict(sender, instance, **kwargs):
     )
     comments_on_the_same_line.update(visible_to_student=False)
     instance.visible_to_student = True
-
diff --git a/core/tests/test_subscription_assignment_service.py b/core/tests/test_subscription_assignment_service.py
index 410dfca75c81b364ef3b4e813251a774daa33a65..79f0f14a6c4e9c2a83233e9372a8e2ed29a147c4 100644
--- a/core/tests/test_subscription_assignment_service.py
+++ b/core/tests/test_subscription_assignment_service.py
@@ -378,9 +378,10 @@ class TestApiEndpoints(APITestCase):
                 "score": 23,
                 "of_submission": response.data['submission']['pk'],
                 "feedback_lines": {
-                    1: {"text": "< some string >"},
-                    2: {"text": "< some string >"}
-                }
+                    1: {"text": "< some string >", "labels": []},
+                    2: {"text": "< some string >", "labels": []}
+                },
+                "labels": [],
             }
         )
         self.assertEqual(status.HTTP_201_CREATED, response.status_code)
diff --git a/frontend/src/components/feedback_labels/FeedbackLabelForm.vue b/frontend/src/components/feedback_labels/FeedbackLabelForm.vue
index 237964288f5a279323517237d7c7cc9e2bc50e9a..9c984168a3c311b4f1d787ed3b87e752958b517b 100644
--- a/frontend/src/components/feedback_labels/FeedbackLabelForm.vue
+++ b/frontend/src/components/feedback_labels/FeedbackLabelForm.vue
@@ -1,13 +1,13 @@
 <template>
   <v-layout wrap justify-start>
     <v-flex ml-3 xs9>
-      <v-text-field
+      <v-text-field id="label-name"
         label="Name"
         v-model="mutableName"
       />
     </v-flex>
     <v-flex ml-3 xs9>
-      <v-textarea
+      <v-textarea id="label-description"
         label="Description"
         v-model="mutableDescription"
         placeholder="The description can be seen when hovering above the label"
diff --git a/frontend/src/components/feedback_labels/FeedbackLabelUpdater.vue b/frontend/src/components/feedback_labels/FeedbackLabelUpdater.vue
index 7b02cc513305cdb43c230b890966973a907efb40..f0074bd27010c229cf720a846a7452a8c6881a12 100644
--- a/frontend/src/components/feedback_labels/FeedbackLabelUpdater.vue
+++ b/frontend/src/components/feedback_labels/FeedbackLabelUpdater.vue
@@ -2,6 +2,7 @@
   <v-layout wrap>
     <v-flex mx-2 xs12>
       <v-autocomplete
+        id="label-update-autocomplete"
         :items="feedbackLabels"
         item-text="name"
         item-value="pk"
diff --git a/frontend/src/components/feedback_labels/FeedbackLabelsList.vue b/frontend/src/components/feedback_labels/FeedbackLabelsList.vue
index db30f7e30ae7c403f77a77129dcef37fbfa7f132..8afcc0c32cc373a70df40e7729d2616a881c5b07 100644
--- a/frontend/src/components/feedback_labels/FeedbackLabelsList.vue
+++ b/frontend/src/components/feedback_labels/FeedbackLabelsList.vue
@@ -15,7 +15,7 @@
     </v-toolbar>
     <v-tabs grow color="teal lighten-1" v-if="showDetail">
       <v-tab>Create</v-tab>
-      <v-tab>Update</v-tab>
+      <v-tab id="update-label-section">Update</v-tab>
       <v-tab-item>
         <feedback-label-form/>
       </v-tab-item>
diff --git a/frontend/src/components/feedback_labels/LabelSelector.vue b/frontend/src/components/feedback_labels/LabelSelector.vue
index 1c4f6bb270e01e18c15899083357661f9644d319..37bb7e6dda399920864aac1fc60d7df4cb0901c8 100644
--- a/frontend/src/components/feedback_labels/LabelSelector.vue
+++ b/frontend/src/components/feedback_labels/LabelSelector.vue
@@ -5,6 +5,7 @@
     <v-layout wrap>
       <v-flex ml-2 sm10>
         <v-autocomplete
+          id="label-add-autocomplete"
           :items="feedbackLabels"
           item-text="name"
           item-value="pk"
diff --git a/frontend/src/components/submission_notes/SubmissionCorrection.vue b/frontend/src/components/submission_notes/SubmissionCorrection.vue
index 58b9b8f55bf4f6083afe699eb40819a47db5f80f..6b3a3bf21ab03b74fea82ecc6ac44d96e40323ff 100644
--- a/frontend/src/components/submission_notes/SubmissionCorrection.vue
+++ b/frontend/src/components/submission_notes/SubmissionCorrection.vue
@@ -43,6 +43,7 @@
         </tr>
       </template>
       <label-selector
+        id="feedback-label-selector"
         :assignedToFeedback="true"
         class="mt-1 elevation-1"
         slot="labels"
diff --git a/frontend/src/components/submission_notes/base/CommentForm.vue b/frontend/src/components/submission_notes/base/CommentForm.vue
index f768e78de4e02bb39305273af141956e84a8bc81..c4a8f5edfeeebd3c055b72d92af698d67c2c62d2 100644
--- a/frontend/src/components/submission_notes/base/CommentForm.vue
+++ b/frontend/src/components/submission_notes/base/CommentForm.vue
@@ -17,6 +17,7 @@
     </v-flex>
     <v-flex lg10 md8 sm8 my-2>
       <label-selector
+        id="comment-label-selector"
         :assignedToFeedback="false"
         :lineNo="this.lineNo"
         :labelsUnchanged="labelsUnchanged"
diff --git a/frontend/src/components/submission_notes/toolbars/ToggleFeedbackVisibilityButton.vue b/frontend/src/components/submission_notes/toolbars/ToggleFeedbackVisibilityButton.vue
index 47aab7ca2d1a3a3a4bde3683b56f912c6af831a6..a579c31040c59f80957ed63cd04c4e65ff09dcdb 100644
--- a/frontend/src/components/submission_notes/toolbars/ToggleFeedbackVisibilityButton.vue
+++ b/frontend/src/components/submission_notes/toolbars/ToggleFeedbackVisibilityButton.vue
@@ -1,5 +1,5 @@
 <template>
-  <v-btn flat color="info" @click="showFeedback = !showFeedback">
+  <v-btn id="feedback-visibility-toggle" flat color="info" @click="showFeedback = !showFeedback">
     <div v-if="showFeedback"> Hide Feedback</div>
     <div v-else> Show Feedback</div>
   </v-btn>
diff --git a/functional_tests/test_export_modal.py b/functional_tests/test_export_modal.py
index a323d1b997fa110f0787672ef6ced5127384bed2..e6af20036648a6088aa782c685f137db3e70722d 100644
--- a/functional_tests/test_export_modal.py
+++ b/functional_tests/test_export_modal.py
@@ -119,13 +119,10 @@ class ExportTestModal(LiveServerTestCase):
     def test_export_instance(self):
         self._login()
         fact.SubmissionFactory()
-        export_btn = self.browser.find_element_by_id('export-btn')
-        export_btn.click()
-        export_instance = self.browser.find_element_by_id('export-list1')
-        export_instance.click()
+        self.browser.find_element_by_id('export-btn').click()
+        self.browser.find_element_by_id('export-list1').click()
         instance_export_modal = self.browser.find_element_by_id('instance-export-modal')
-        instance_export_btn = instance_export_modal.find_element_by_id('instance-export-dl')
-        instance_export_btn.click()
+        instance_export_modal.find_element_by_id('instance-export-dl').click()
         WebDriverWait(self.browser, 10).until(expect_file_to_be_present(JSON_EXPORT_FILE))
         with open(JSON_EXPORT_FILE) as f:
             data = json.load(f)
diff --git a/functional_tests/test_feedback_creation.py b/functional_tests/test_feedback_creation.py
index 042700d286848d7aa4f2388176dc5c36058d39f6..1e1bd33536ffbc25893408d982755ddea408e8f6 100644
--- a/functional_tests/test_feedback_creation.py
+++ b/functional_tests/test_feedback_creation.py
@@ -1,13 +1,12 @@
 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)
+                                   go_to_subscription, wait_until_code_changes,
+                                   reconstruct_submission_code)
 from util import factory_boys as fact
 
 
@@ -38,38 +37,6 @@ class UntestedParent:
         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):
-            WebDriverWait(self.browser, 10).until(subscriptions_loaded_cond(self.browser))
-            tasks = self.browser.find_element_by_name('subscription-list')
-            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, 10).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, 10).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
@@ -87,14 +54,14 @@ class UntestedParent:
 
         def test_student_text_is_correctly_displayed(self):
             self._login()
-            self._go_to_subscription()
-            code = self._reconstruct_submission_code()
+            go_to_subscription(self)
+            code = reconstruct_submission_code(self)
             # 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()
+            go_to_subscription(self)
             sub_type_el = self.browser.find_element_by_id('submission-type')
             title = sub_type_el.find_element_by_class_name('title')
             self.assertEqual(
@@ -113,7 +80,7 @@ class UntestedParent:
             for submission in Submission.objects.all():
                 test = fact.TestFactory.create(submission=submission, annotation='This is a test')
             self._login()
-            self._go_to_subscription()
+            go_to_subscription(self)
             tests = self.browser.find_element_by_id('submission-tests')
             name_label = tests.find_element_by_name('test-name-label')
             name_label.click()
@@ -123,32 +90,22 @@ class UntestedParent:
             WebDriverWait(self.browser, 10).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()
+            go_to_subscription(self)
+            code = reconstruct_submission_code(self)
             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, 10).until(
-                self.wait_until_code_changes(code)
+                wait_until_code_changes(self, 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()
+            go_to_subscription(self)
             self.browser.find_element_by_id('score-zero').click()
             submit_btn = self.browser.find_element_by_id('submit-feedback')
             submit_btn.click()
@@ -157,19 +114,19 @@ class UntestedParent:
 
         def test_can_give_zero_score(self):
             self._login()
-            self._go_to_subscription()
-            code = self._reconstruct_submission_code()
+            go_to_subscription(self)
+            code = reconstruct_submission_code(self)
             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, 10).until(self.wait_until_code_changes(code))
+            WebDriverWait(self.browser, 10).until(wait_until_code_changes(self, 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()
+            go_to_subscription(self)
+            code = reconstruct_submission_code(self)
 
             # give half full score
             score_input = self.browser.find_element_by_id('score-input')
@@ -184,7 +141,7 @@ class UntestedParent:
             submit_btn = self.browser.find_element_by_id('submit-feedback')
             submit_btn.click()
             WebDriverWait(self.browser, 10).until(
-                self.wait_until_code_changes(code)
+                wait_until_code_changes(self, code)
             )
             submission_for_code = Submission.objects.get(text=code)
             self.assertEqual(self.sub_type.full_score // 2, submission_for_code.feedback.score)
@@ -203,23 +160,23 @@ class UntestedParent:
 
         def test_can_skip_submission(self):
             self._login()
-            self._go_to_subscription()
-            code = self._reconstruct_submission_code()
+            go_to_subscription(self)
+            code = reconstruct_submission_code(self)
             self.browser.find_element_by_id('skip-submission').click()
-            WebDriverWait(self.browser, 10).until(self.wait_until_code_changes(code))
+            WebDriverWait(self.browser, 10).until(wait_until_code_changes(self, code))
 
         def test_can_validate_submission(self):
             self._login()
-            self._go_to_subscription()
+            go_to_subscription(self)
 
             def correct():
-                code = self._reconstruct_submission_code()
+                code = reconstruct_submission_code(self)
                 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()
                 return code
             code = correct()
-            WebDriverWait(self.browser, 10).until(self.wait_until_code_changes(code))
+            WebDriverWait(self.browser, 10).until(wait_until_code_changes(self, code))
             correct()
 
             sub_url = 'subscription/' + str(self.sub_type.pk) + '/ended'
@@ -232,14 +189,14 @@ class UntestedParent:
             fact.UserAccountFactory(username=user_snd, password=password)
 
             login(self.browser, self.live_server_url, user_snd, password)
-            self._go_to_subscription(stage='validate')
+            go_to_subscription(self, stage='validate')
             self.write_comments_on_lines([(0, 'I disagree'), (1, 'Full points!')])
-            code_final = self._reconstruct_submission_code()
+            code_final = reconstruct_submission_code(self)
             self.browser.find_element_by_id('score-full').click()
             self.browser.find_element_by_id('submit-feedback').click()
 
-            WebDriverWait(self.browser, 10).until(self.wait_until_code_changes(code_final))
-            code_non_final = self._reconstruct_submission_code()
+            WebDriverWait(self.browser, 10).until(wait_until_code_changes(self, code_final))
+            code_non_final = reconstruct_submission_code(self)
             self.browser.find_element_by_class_name('final-checkbox').click()
             self.browser.find_element_by_id('submit-feedback').click()
 
@@ -254,8 +211,8 @@ class UntestedParent:
             fact.UserAccountFactory(username=user_rev, password=password, role=role)
             login(self.browser, self.live_server_url, user_rev, password)
 
-            self._go_to_subscription('conflict')
-            code = self._reconstruct_submission_code()
+            go_to_subscription(self, 'conflict')
+            code = reconstruct_submission_code(self)
             self.assertEqual(code, code_non_final)
 
             submission_for_code = Submission.objects.get(text=code_final)
diff --git a/functional_tests/test_feedback_label_system.py b/functional_tests/test_feedback_label_system.py
new file mode 100644
index 0000000000000000000000000000000000000000..44d63cce177cfbd6d4fcf60228050f02ca0bdda6
--- /dev/null
+++ b/functional_tests/test_feedback_label_system.py
@@ -0,0 +1,342 @@
+from django.test import LiveServerTestCase
+from selenium import webdriver
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+from selenium.webdriver.support import expected_conditions as ec
+
+from core.models import FeedbackLabel
+from functional_tests.util import (login, create_browser, reset_browser_after_test,
+                                   query_returns_object, go_to_subscription,
+                                   reconstruct_submission_code, wait_until_code_changes)
+from util import factory_boys as fact
+
+
+class FeedbackLabelSystemTest(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):
+        super().setUp()
+        self.username = 'tut'
+        self.password = 'p'
+        fact.UserAccountFactory(
+            username=self.username,
+            password=self.password,
+        )
+        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)
+
+    # creates a new label where colour_num is
+    # the index of the colour to click on the colour picker
+    def create_label(self, name, description, colour_num):
+        self.browser.find_element_by_id('label-name').send_keys(name)
+        self.browser.find_element_by_id('label-description').send_keys(description)
+        self.browser.find_elements_by_class_name('vc-compact-color-item')[colour_num].click()
+        self.browser.find_element_by_id('create-label-btn').click()
+        WebDriverWait(self.browser, 10).until(query_returns_object(FeedbackLabel, name=name))
+
+    # updates an already existing label with the given arguments
+    def update_label(self, old_name, new_name, description, colour_num):
+        self.browser.find_element_by_id('update-label-section').click()
+        WebDriverWait(self.browser, 2).until(
+            ec.element_to_be_clickable((By.ID, 'label-update-autocomplete'))
+        )
+        self.browser.find_element_by_id('label-update-autocomplete').send_keys(old_name)
+        self.browser.find_element_by_link_text(old_name).click()
+        self.browser.find_element_by_xpath(
+            '//div[@class="v-window-item" and @style=""]//input[@id="label-name"]'
+        ).send_keys(new_name)
+
+        self.browser.find_element_by_xpath(
+            '//div[@class="v-window-item" and @style=""]//textarea[@id="label-description"]'
+        ).send_keys(description)
+
+        self.browser.find_elements_by_xpath(
+            '//div[@class="v-window-item" and @style=""]//li[@class="vc-compact-color-item"]'
+        )[colour_num].click()
+        self.browser.find_element_by_id('update-label-btn').click()
+        WebDriverWait(self.browser, 10).until(
+            query_returns_object(FeedbackLabel, name=old_name + new_name)
+        )
+
+    def assign_label_to_feedback(self, name):
+        self.browser.find_element_by_xpath(
+            '//div[@id="feedback-label-selector"]//input[@id="label-add-autocomplete"]'
+        ).send_keys(name)
+        self.browser.find_element_by_link_text(name).click()
+
+    def remove_label_from_feedback(self, name):
+        self.browser.find_element_by_xpath(
+            f'//div[@id="feedback-label-selector"]//span[@class="v-chip__content" '
+            f'and contains(text(), "{name}")]//div[@class="v-chip__close"]'
+        ).click()
+
+    def assign_label_to_comment_line(self, line, name):
+        self.browser.find_element_by_xpath(
+            f'//div[@class="v-btn__content" and contains(text(), "{line}")]'
+        ).click()
+        self.browser.find_element_by_xpath(
+            '//div[@id="comment-label-selector"]//input[@id="label-add-autocomplete"]'
+        ).send_keys(name)
+        self.browser.find_element_by_link_text(name).click()
+        self.browser.find_element_by_id('submit-comment').click()
+
+    def remove_label_from_comment_line(self, line, name):
+        self.browser.find_element_by_xpath(
+            f'//tr[@id="sub-line-{line}"]//span[contains(text(), "{name}")]'
+            '//div[@class="v-chip__close"]'
+        ).click()
+
+    def test_can_create_label(self):
+        self._login()
+        label_name = 'test name'
+        label_desc = 'test description'
+        self.create_label(label_name, label_desc, 3)
+        created_label = FeedbackLabel.objects.get(name='test name')
+
+        self.assertEqual(created_label.name, label_name)
+        self.assertEqual(created_label.description, label_desc)
+
+    def test_can_not_create_duplicate_label(self):
+        self._login()
+        label_name = 'duplicate'
+        label_desc = 'duplicate test'
+        self.create_label(label_name, label_desc, 3)
+        self.create_label(label_name, label_desc, 3)
+        WebDriverWait(self.browser, 2).until(
+            ec.visibility_of_element_located((By.CLASS_NAME, 'notification-content'))
+        )
+        notification = self.browser.find_element_by_class_name('notification-content')
+
+        labels = FeedbackLabel.objects.all()
+        self.assertIn('already exists', notification.text)
+        self.assertEqual(len(labels), 1)
+
+    def test_can_update_label(self):
+        self._login()
+        self.create_label('test', 'some desc', 1)
+        self.update_label('test', 'updated', 'updated desc', 3)
+
+        label = FeedbackLabel.objects.get(name='testupdated')
+
+        self.assertEqual(label.name, 'testupdated')
+        self.assertEqual(label.description, 'some descupdated desc')
+
+    def test_can_assign_label_to_feedback_draft(self):
+        self._login()
+        self.create_label('test', 'some desc', 1)
+        go_to_subscription(self)
+        code = reconstruct_submission_code(self)
+        self.assign_label_to_feedback('test')
+        labels = self.browser.find_elements_by_xpath(
+            '//div[@id="feedback-label-selector"]//div[contains(text(), "WILL BE ADDED")]/..//*'
+        )
+        self.assertGreater(len(labels), 1)
+
+        self.browser.find_element_by_id('score-full').click()
+        self.browser.find_element_by_id('submit-feedback').click()
+        WebDriverWait(self.browser, 10).until(
+            wait_until_code_changes(self, code)
+        )
+
+        label = FeedbackLabel.objects.get(name='test')
+        self.assertEqual(len(label.feedback.all()), 1)
+
+    def test_can_remove_label_from_feedback_draft(self):
+        self._login()
+        self.create_label('test', 'some desc', 1)
+        go_to_subscription(self)
+        self.assign_label_to_feedback('test')
+        self.remove_label_from_feedback('test')
+        labels = self.browser.find_elements_by_xpath(
+            '//div[@id="feedback-label-selector"]//div[contains(text(), "WILL BE ADDED")]/..//*'
+        )
+
+        self.assertEqual(len(labels), 1)
+
+    def test_can_remove_label_from_submitted_feedback(self):
+        self._login()
+        self.create_label('test', 'some desc', 1)
+        go_to_subscription(self)
+        code = reconstruct_submission_code(self)
+        self.assign_label_to_feedback('test')
+        self.browser.find_element_by_id('score-full').click()
+        self.browser.find_element_by_id('submit-feedback').click()
+        WebDriverWait(self.browser, 10).until(
+            wait_until_code_changes(self, code)
+        )
+
+        # logs out user
+        reset_browser_after_test(self.browser, self.live_server_url)
+
+        username = 'tut_snd'
+        password = 'p'
+        fact.UserAccountFactory(username=username, password=password)
+        login(self.browser, self.live_server_url, username, password)
+
+        go_to_subscription(self, stage='validate')
+        code = reconstruct_submission_code(self)
+        self.remove_label_from_feedback('test')
+        removed = self.browser.find_elements_by_xpath(
+            '//div[@id="feedback-label-selector"]//div[contains(text(), "WILL BE REMOVED")]/..//*'
+        )
+        self.assertGreater(len(removed), 1)
+
+        self.browser.find_element_by_id('submit-feedback').click()
+        sub_url = 'subscription/' + str(self.sub_type.pk) + '/ended'
+        WebDriverWait(self.browser, 10).until(ec.url_contains(sub_url))
+        label = FeedbackLabel.objects.get(name='test')
+
+        self.assertEqual(len(label.feedback.all()), 0)
+
+    def test_can_add_label_to_submitted_feedback(self):
+        self._login()
+        self.create_label('test', 'some test dec', 1)
+        self.create_label('add', 'add test dec', 4)
+        go_to_subscription(self)
+        code = reconstruct_submission_code(self)
+        self.assign_label_to_feedback('test')
+        self.browser.find_element_by_id('score-full').click()
+        self.browser.find_element_by_id('submit-feedback').click()
+        WebDriverWait(self.browser, 10).until(
+            wait_until_code_changes(self, code)
+        )
+
+        # logs out user
+        reset_browser_after_test(self.browser, self.live_server_url)
+
+        username = 'tut_snd'
+        password = 'p'
+        fact.UserAccountFactory(username=username, password=password)
+        login(self.browser, self.live_server_url, username, password)
+        go_to_subscription(self, stage='validate')
+
+        code = reconstruct_submission_code(self)
+        self.assign_label_to_feedback('add')
+        added = self.browser.find_elements_by_xpath(
+            '//div[@id="feedback-label-selector"]//div[contains(text(), "WILL BE ADDED")]/..//*'
+        )
+        self.assertGreater(len(added), 1)
+
+        self.browser.find_element_by_id('submit-feedback').click()
+        sub_url = 'subscription/' + str(self.sub_type.pk) + '/ended'
+        WebDriverWait(self.browser, 10).until(ec.url_contains(sub_url))
+        label = FeedbackLabel.objects.get(name='add')
+
+        self.assertEqual(len(label.feedback.all()), 1)
+
+    def test_can_assign_label_to_comment(self):
+        self._login()
+        self.create_label('test', 'some desc', 1)
+        go_to_subscription(self)
+        code = reconstruct_submission_code(self)
+        comment_line = 1
+        self.assign_label_to_comment_line(comment_line, 'test')
+        added = self.browser.find_elements_by_xpath(
+            f'//tr[@id="sub-line-{comment_line}"]//div[contains(text(), "WILL BE ADDED")]/..//*'
+        )
+        self.assertGreater(len(added), 1)
+
+        self.browser.find_element_by_id('score-full').click()
+        self.browser.find_element_by_id('submit-feedback').click()
+        WebDriverWait(self.browser, 10).until(
+            wait_until_code_changes(self, code)
+        )
+
+        label = FeedbackLabel.objects.get(name='test')
+        self.assertEqual(len(label.feedback_comments.all()), 1)
+
+    def test_can_remove_label_from_submitted_comment(self):
+        self._login()
+        self.create_label('test', 'some desc', 1)
+        go_to_subscription(self)
+        code = reconstruct_submission_code(self)
+        comment_line = 1
+        self.assign_label_to_comment_line(comment_line, 'test')
+        self.browser.find_element_by_id('score-full').click()
+        self.browser.find_element_by_id('submit-feedback').click()
+        WebDriverWait(self.browser, 10).until(
+            wait_until_code_changes(self, code)
+        )
+
+        # logs out user
+        reset_browser_after_test(self.browser, self.live_server_url)
+
+        username = 'tut_snd'
+        password = 'p'
+        fact.UserAccountFactory(username=username, password=password)
+        login(self.browser, self.live_server_url, username, password)
+
+        go_to_subscription(self, stage='validate')
+        code = reconstruct_submission_code(self)
+        self.browser.find_element_by_id('feedback-visibility-toggle').click()
+        self.remove_label_from_comment_line(comment_line, 'test')
+        removed = self.browser.find_elements_by_xpath(
+            '//div[@id="feedback-label-selector"]//div[contains(text(), "WILL BE REMOVED")]/..//*'
+        )
+        self.assertGreaterEqual(len(removed), 1)
+
+        self.browser.find_element_by_id('submit-feedback').click()
+        sub_url = 'subscription/' + str(self.sub_type.pk) + '/ended'
+        WebDriverWait(self.browser, 10).until(ec.url_contains(sub_url))
+        label = FeedbackLabel.objects.get(name='test')
+
+        # comment still exists but is now invisible
+        self.assertEqual(label.feedback_comments.all()[0].visible_to_student, False)
+
+    def test_can_add_label_to_submitted_comment(self):
+        self._login()
+        self.create_label('test', 'some desc', 1)
+        self.create_label('add', 'add test desc', 4)
+        go_to_subscription(self)
+        code = reconstruct_submission_code(self)
+        comment_line = 1
+        self.assign_label_to_comment_line(comment_line, 'test')
+        self.browser.find_element_by_id('score-full').click()
+        self.browser.find_element_by_id('submit-feedback').click()
+        WebDriverWait(self.browser, 10).until(
+            wait_until_code_changes(self, code)
+        )
+
+        # logs out user
+        reset_browser_after_test(self.browser, self.live_server_url)
+
+        username = 'tut_snd'
+        password = 'p'
+        fact.UserAccountFactory(username=username, password=password)
+        login(self.browser, self.live_server_url, username, password)
+
+        go_to_subscription(self, stage='validate')
+        code = reconstruct_submission_code(self)
+        self.browser.find_element_by_id('feedback-visibility-toggle').click()
+        self.assign_label_to_comment_line(comment_line, 'add')
+        added = self.browser.find_elements_by_xpath(
+            '//div[@id="feedback-label-selector"]//div[contains(text(), "WILL BE ADDED")]/..//*'
+        )
+        self.assertGreaterEqual(len(added), 1)
+
+        self.browser.find_element_by_id('submit-feedback').click()
+        sub_url = 'subscription/' + str(self.sub_type.pk) + '/ended'
+        WebDriverWait(self.browser, 10).until(ec.url_contains(sub_url))
+        label = FeedbackLabel.objects.get(name='add')
+
+        # comment still exists but is now invisible
+        self.assertEqual(len(label.feedback_comments.all()), 1)
diff --git a/functional_tests/util.py b/functional_tests/util.py
index 0cec31a72cbe2b775757dd0fce1d020a929b4ec1..be898df2ae65a531e4d8b32ea67e2a54c027d5cc 100644
--- a/functional_tests/util.py
+++ b/functional_tests/util.py
@@ -10,6 +10,8 @@ from selenium.webdriver.remote.webelement import WebElement
 from selenium.webdriver.support import expected_conditions as ec
 from selenium.webdriver.support.ui import WebDriverWait
 from selenium.common.exceptions import StaleElementReferenceException
+from selenium.webdriver.common.by import By
+from django.core.exceptions import ObjectDoesNotExist
 
 
 def create_browser() -> webdriver.Firefox:
@@ -69,3 +71,60 @@ def subscriptions_loaded_cond(browser):
                 pass
         return False
     return loaded
+
+
+# returns a function that can be used as a callback for WebDriverWait
+# the resulting functions returns True if the given query would return at least one object
+def query_returns_object(model_class, **kwargs):
+    def query(*args):
+        try:
+            model_class.objects.get(**kwargs)
+        except ObjectDoesNotExist:
+            return False
+        return True
+    return query
+
+
+# stage can be 'initial', 'validate', or 'conflict'
+def go_to_subscription(self, stage='initial', sub_type=None):
+    WebDriverWait(self.browser, 10).until(subscriptions_loaded_cond(self.browser))
+    tasks = self.browser.find_element_by_name('subscription-list')
+    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, 10).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, 10).until(ec.url_contains('subscription'))
+
+
+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)
+
+
+def wait_until_code_changes(self, code):
+    def condition(*args):
+        try:
+            # code might change during the call resulting in the exception
+            new_code = reconstruct_submission_code(self)
+        except StaleElementReferenceException:
+            return False
+        return code != new_code
+    return condition