diff --git a/functional_tests/base.py b/functional_tests/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..624cfd34391ca9a4f047e1437ba8ae97e66472d5
--- /dev/null
+++ b/functional_tests/base.py
@@ -0,0 +1,37 @@
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
+from selenium import webdriver
+from selenium.common.exceptions import WebDriverException
+import time
+
+MAX_WAIT = 10
+
+
+class FunctionalTest(StaticLiveServerTestCase):
+    def setUp(self):
+        self.browser = webdriver.Chrome()
+
+    def tearDown(self):
+        self.browser.quit()
+
+    def wait_for_row_in_list_table(self, row_text):
+        start_time = time.time()
+        while True:
+            try:
+                table = self.browser.find_element_by_id('id_list_table')
+                rows = table.find_elements_by_tag_name('tr')
+                self.assertIn(row_text, [row.text for row in rows])
+                return
+            except (AssertionError, WebDriverException) as e:
+                if time.time() - start_time > MAX_WAIT:
+                    raise e
+                time.sleep(0.5)
+    
+    def wait_for(self, fn):
+        start_time = time.time()
+        while True:
+            try:
+                return fn()
+            except (AssertionError, WebDriverException) as e:
+                if time.time() - start_time > MAX_WAIT:
+                    raise e
+                time.sleep(0.5)
diff --git a/functional_tests/test_layout_and_styling.py b/functional_tests/test_layout_and_styling.py
new file mode 100644
index 0000000000000000000000000000000000000000..135aa45a8bbd1e06f3669fdff52f84c5792dbe69
--- /dev/null
+++ b/functional_tests/test_layout_and_styling.py
@@ -0,0 +1,28 @@
+from selenium.webdriver.common.keys import Keys
+from .base import FunctionalTest
+
+
+class LayoutAndStylingTest(FunctionalTest):
+    def test_layout_and_styling(self):
+        # Edith goes to the home page
+        self.browser.get(self.live_server_url)
+        self.browser.set_window_size(1024, 768)
+        # She notices the input box is nicely centered
+        inputbox = self.browser.find_element_by_id('id_new_item')
+        self.assertAlmostEqual(
+            inputbox.location['x'] + inputbox.size['width'] / 2,
+            512,
+            delta=10
+        )
+
+        # She starts a new list and sees the input is nicely
+        # centered there too
+        inputbox.send_keys('testing')
+        inputbox.send_keys(Keys.ENTER)
+        self.wait_for_row_in_list_table('1: testing')
+        inputbox = self.browser.find_element_by_id('id_new_item')
+        self.assertAlmostEqual(
+            inputbox.location['x'] + inputbox.size['width'] / 2,
+            512,
+            delta=10
+        )
diff --git a/functional_tests/test_list_item_validation.py b/functional_tests/test_list_item_validation.py
new file mode 100644
index 0000000000000000000000000000000000000000..65aa7d3d7a578cbc7d2f015adcbe93876025b114
--- /dev/null
+++ b/functional_tests/test_list_item_validation.py
@@ -0,0 +1,38 @@
+from selenium.webdriver.common.keys import Keys
+from unittest import skip
+from .base import FunctionalTest
+
+
+class ItemValidationTest(FunctionalTest):
+
+    def test_cannot_add_empty_list_items(self):
+        # Edith goes to the home page and accidentally tries to submit
+        # an empty list item. She hits Enter on the empty input box
+        self.browser.get(self.live_server_url)
+        self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)
+        # The home page refreshes, and there is an error message saying
+        # that list items cannot be blank
+        self.wait_for(lambda: self.assertEqual(
+            self.browser.find_element_by_css_selector('.has-error').text,
+            "You can't have an empty list item"
+        ))
+        
+        # She tries again with some text for the item, which now works
+        self.browser.find_element_by_id('id_new_item').send_keys('Buy milk')
+        self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)
+        self.wait_for_row_in_list_table('1: Buy milk')
+
+        # Perversely, she now decides to submit a second blank list item
+        self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)
+
+        # She receives a similar warning on the list page
+        self.wait_for(lambda: self.assertEqual(
+            self.browser.find_element_by_css_selector('.has-error').text,
+            "You can't have an empty list item"
+        ))
+
+        # And she can correct it by filling some text in
+        self.browser.find_element_by_id('id_new_item').send_keys('Make tea')
+        self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)
+        self.wait_for_row_in_list_table('1: Buy milk')
+        self.wait_for_row_in_list_table('2: Make tea')
diff --git a/functional_tests/tests.py b/functional_tests/test_simple_list_creation.py
similarity index 58%
rename from functional_tests/tests.py
rename to functional_tests/test_simple_list_creation.py
index d82578259a92ca17db3f5fc088222eb3d0106f88..3d9de52003394c17a772ccf8c630c667de2b908f 100644
--- a/functional_tests/tests.py
+++ b/functional_tests/test_simple_list_creation.py
@@ -1,33 +1,9 @@
-from selenium import webdriver
-from selenium.common.exceptions import WebDriverException
+from .base import FunctionalTest
 from selenium.webdriver.common.keys import Keys
-from django.contrib.staticfiles.testing import StaticLiveServerTestCase
 import time
-import os
 
-MAX_WAIT = 10
-
-
-class NewVisitorTest(StaticLiveServerTestCase):
-    def setUp(self):
-        self.browser = webdriver.Chrome()
-
-    def tearDown(self):
-        self.browser.quit()
-
-    def wait_for_row_in_list_table(self, row_text):
-        start_time = time.time()
-        while True:
-            try:
-                table = self.browser.find_element_by_id('id_list_table')
-                rows = table.find_elements_by_tag_name('tr')
-                self.assertIn(row_text, [row.text for row in rows])
-                return
-            except (AssertionError, WebDriverException) as e:
-                if time.time() - start_time > MAX_WAIT:
-                    raise e
-                time.sleep(0.5)
 
+class NewVisitorTest(FunctionalTest):
     def test_can_start_a_list_and_retrieve_it_later(self):
         # Edith has heard about a cool new online to-do app. She goes
         # to check out its homepage
@@ -97,32 +73,3 @@ class NewVisitorTest(StaticLiveServerTestCase):
             comment.text,
             'oh tidak'
         )
-
-        # Edith wonders whether the site will remember her list. Then she sees
-        # that the site has generated a unique URL for her -- there is some
-        # explanatory text to that effect.
-        self.fail('Finish the test!')
-
-    def test_layout_and_styling(self):
-        # Edith goes to the home page
-        self.browser.get(self.live_server_url)
-        self.browser.set_window_size(1024, 768)
-        # She notices the input box is nicely centered
-        inputbox = self.browser.find_element_by_id('id_new_item')
-        self.assertAlmostEqual(
-            inputbox.location['x'] + inputbox.size['width'] / 2,
-            512,
-            delta=10
-        )
-
-        # She starts a new list and sees the input is nicely
-        # centered there too
-        inputbox.send_keys('testing')
-        inputbox.send_keys(Keys.ENTER)
-        self.wait_for_row_in_list_table('1: testing')
-        inputbox = self.browser.find_element_by_id('id_new_item')
-        self.assertAlmostEqual(
-            inputbox.location['x'] + inputbox.size['width'] / 2,
-            512,
-            delta=10
-        )
diff --git a/latihan2/tests/__init__.py b/latihan2/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/latihan2/tests/test_model.py b/latihan2/tests/test_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..efb1c9b1afa78bb6bcbe32c14eb809b2331ca582
--- /dev/null
+++ b/latihan2/tests/test_model.py
@@ -0,0 +1,18 @@
+from django.test import TestCase
+from latihan2.models import Item
+
+
+class ItemModelTest(TestCase):
+    def test_saving_and_retrieving_items(self):
+        first_item = Item()
+        first_item.text = 'The first (ever) list item'
+        first_item.save()
+        second_item = Item()
+        second_item.text = 'Item the second'
+        second_item.save()
+        saved_items = Item.objects.all()
+        self.assertEqual(saved_items.count(), 2)
+        first_saved_item = saved_items[0]
+        second_saved_item = saved_items[1]
+        self.assertEqual(first_saved_item.text, 'The first (ever) list item')
+        self.assertEqual(second_saved_item.text, 'Item the second')
diff --git a/latihan2/tests.py b/latihan2/tests/test_views.py
similarity index 80%
rename from latihan2/tests.py
rename to latihan2/tests/test_views.py
index 887fb4fd2bec37deb464a0092dcb8964352fac47..b16d6d188abb9a889db00b8d3051c2985fe82375 100644
--- a/latihan2/tests.py
+++ b/latihan2/tests/test_views.py
@@ -1,6 +1,5 @@
 from django.urls import resolve
 from django.test import TestCase
-from django.http import HttpRequest
 
 from latihan2.views import home_page
 from latihan2.models import Item
@@ -67,19 +66,3 @@ class HomePageTest(TestCase):
 
         response = self.client.get('/')
         self.assertIn('oh tidak', response.content.decode())
-
-
-class ItemModelTest(TestCase):
-    def test_saving_and_retrieving_items(self):
-        first_item = Item()
-        first_item.text = 'The first (ever) list item'
-        first_item.save()
-        second_item = Item()
-        second_item.text = 'Item the second'
-        second_item.save()
-        saved_items = Item.objects.all()
-        self.assertEqual(saved_items.count(), 2)
-        first_saved_item = saved_items[0]
-        second_saved_item = saved_items[1]
-        self.assertEqual(first_saved_item.text, 'The first (ever) list item')
-        self.assertEqual(second_saved_item.text, 'Item the second')