diff --git a/soitool/inline_editable_soi_view.py b/soitool/inline_editable_soi_view.py
index e43fcfda6b341421e3265929ef5c1fdd01f70b7c..e56d498a095c42df4ccf139e0225b010747fdb97 100644
--- a/soitool/inline_editable_soi_view.py
+++ b/soitool/inline_editable_soi_view.py
@@ -59,7 +59,7 @@ class InlineEditableSOIView(QScrollArea):
 
         self.soi = soi
 
-        self.number_of_pages = 0
+        self.number_of_pages = 1
         self.proxies = set()
 
         # necessary to make the scroll area fill the space it's given
@@ -75,8 +75,9 @@ class InlineEditableSOIView(QScrollArea):
         self.ensure_proxies()
         self.update_pages()
 
-        # update pages after SOI is reorganized
+        # add listeners to react properly to SOI changes
         self.soi.add_reorganization_listener(self.update_pages)
+        self.soi.add_new_module_listener(self.ensure_proxies)
 
         # self.launch_auto_zoom()
 
@@ -135,8 +136,11 @@ class InlineEditableSOIView(QScrollArea):
             painter.end()
 
     def update_number_of_pages(self):
-        """Make sure number of pages excactly fits SOI modules."""
-        required_pages = 0
+        """Make sure number of pages excactly fits SOI modules.
+
+        The minimum page count is 1.
+        """
+        required_pages = 1
         for module in self.soi.modules:
             if module["meta"]["page"] > required_pages:
                 required_pages += 1
diff --git a/soitool/media/placeholder.png b/soitool/media/placeholder.png
new file mode 100644
index 0000000000000000000000000000000000000000..0c6e7755d9ae4445afbca21af810d78ab139025a
Binary files /dev/null and b/soitool/media/placeholder.png differ
diff --git a/soitool/media/tablemodule.png b/soitool/media/tablemodule.png
new file mode 100644
index 0000000000000000000000000000000000000000..bc9d143b46ea013cc6bc0c1a06873ec09952bfdc
Binary files /dev/null and b/soitool/media/tablemodule.png differ
diff --git a/soitool/modules/module_base.py b/soitool/modules/module_base.py
index 630cdaeb4c5e7b982b52b49413cbde406abedb74..82b78aa26966556f35c77ebfb0d3fd49f82bf37c 100644
--- a/soitool/modules/module_base.py
+++ b/soitool/modules/module_base.py
@@ -16,3 +16,13 @@ class ModuleBase(ABC):
     def render_onto_pdf(self):
         """Abstract method, should be implemented by derived class."""
         raise NotImplementedError
+
+    @staticmethod
+    def get_user_friendly_name():
+        """Abstract method, should be implemented by derived class."""
+        raise NotImplementedError
+
+    @staticmethod
+    def get_icon():
+        """Abstract method, should be implemented by derived class."""
+        raise NotImplementedError
diff --git a/soitool/modules/module_table.py b/soitool/modules/module_table.py
index e5478ef547fb7f5cec916aab6afd365bc12ab8b4..0a0389fa4df3006c16ccb0b5fdb291226701b79f 100644
--- a/soitool/modules/module_table.py
+++ b/soitool/modules/module_table.py
@@ -1,6 +1,7 @@
 """Module containing subclassed SOIModule (QTableWidget, ModuleBase)."""
 from PySide2.QtWidgets import QTableWidget, QTableWidgetItem
 from PySide2 import QtGui, QtCore
+from PySide2.QtGui import QIcon
 from soitool.modules.module_base import ModuleBase
 
 HEADER_FONT = QtGui.QFont()
@@ -181,3 +182,13 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta):
 
     def render_onto_pdf(self):
         """Render onto pdf."""
+
+    @staticmethod
+    def get_user_friendly_name():
+        """Get user-friendly name of module."""
+        return "Generisk tabell"
+
+    @staticmethod
+    def get_icon():
+        """Get icon of module."""
+        return QIcon("soitool/media/tablemodule.png")
diff --git a/soitool/new_module_dialog.py b/soitool/new_module_dialog.py
new file mode 100644
index 0000000000000000000000000000000000000000..2138c97961ac469ca49f4e20ff2aa1473815edb3
--- /dev/null
+++ b/soitool/new_module_dialog.py
@@ -0,0 +1,140 @@
+"""Provides a dialog where the user can select new module to be added."""
+
+from PySide2.QtWidgets import (
+    QHBoxLayout,
+    QVBoxLayout,
+    QPushButton,
+    QDialog,
+    QListWidget,
+    QLineEdit,
+    QListWidgetItem,
+    QLabel,
+    QCheckBox,
+)
+from PySide2.QtCore import QSize, Qt
+from PySide2.QtGui import QIcon
+from soitool.modules.module_table import TableModule
+from soitool.modules.module_base import ModuleBase
+
+
+class ModulePlaceholder(ModuleBase):
+    """Dummy module used only to fill dialog with content while developing."""
+
+    def set_pos(self, pos):
+        """Not used."""
+        raise NotImplementedError
+
+    def get_size(self):
+        """Not used."""
+        raise NotImplementedError
+
+    def render_onto_pdf(self):
+        """Not used."""
+        raise NotImplementedError
+
+    @staticmethod
+    def get_user_friendly_name():
+        """Get placeholder name."""
+        return "Modul"
+
+    @staticmethod
+    def get_icon():
+        """Get standard placeholder icon."""
+        return QIcon("soitool/media/placeholder.png")
+
+
+# Constant holding all modules the user can choose from. This is intended as a
+# point of extensibility. Further modules that are developed can simply be
+# placed here, and the rest of the program will respect them.
+MODULE_CHOICES = [
+    TableModule,
+    ModulePlaceholder,
+    ModulePlaceholder,
+    ModulePlaceholder,
+]
+
+
+class NewModuleDialogListItem(QListWidgetItem):
+    """Custom list item that includes a widget implementation of a module.
+
+    Parameters
+    ----------
+    widget : subclass of ModuleBase
+        The widget that implements the module.
+    """
+
+    def __init__(self, widget):
+        super().__init__(widget.get_icon(), widget.get_user_friendly_name())
+        self.widget_implementation = widget
+
+
+class NewModuleDialog(QDialog):
+    """Dialog to let user select module to be added to the SOI.
+
+    When the dialog is closed the user's choice is fetched by directly
+    inspecting the child widgets, for example: `self.list_module_choices` and
+    `self.line_edit_name`. The dialog is otherwise used excactly as it's
+    superclass QDialog: https://doc.qt.io/qt-5/qdialog.html
+
+    Parameters
+    ----------
+    module_choices : list of subclasses of ModuleBase
+        list of modules the user can choose from
+    """
+
+    def __init__(self, module_choices=None):
+        super().__init__()
+        self.setWindowTitle("Ny modul")
+
+        if module_choices is None:
+            module_choices = MODULE_CHOICES
+
+        self.button_add = QPushButton("Legg til")
+        self.button_cancel = QPushButton("Avbryt")
+
+        self.button_add.clicked.connect(self.accept)
+        self.button_cancel.clicked.connect(self.reject)
+
+        self.layout_buttons = QHBoxLayout()
+        self.layout_buttons.addWidget(self.button_add)
+        self.layout_buttons.addWidget(self.button_cancel)
+
+        self.list_module_choices = QListWidget()
+        self.list_module_choices.setViewMode(QListWidget.IconMode)
+        self.list_module_choices.setIconSize(QSize(100, 100))
+        self.list_module_choices.setResizeMode(QListWidget.Adjust)
+
+        for i, module_choice in enumerate(module_choices):
+            self.list_module_choices.insertItem(
+                i, NewModuleDialogListItem(module_choice),
+            )
+
+        # set the first element as the default choice, whatever it is
+        self.list_module_choices.item(0).setSelected(True)
+
+        self.line_edit_name = QLineEdit()
+        self.line_edit_name.setPlaceholderText(
+            "La stå tom for automatisk navn"
+        )
+        self.label_name = QLabel("Navn:")
+
+        self.layout_name = QHBoxLayout()
+        self.layout_name.addWidget(self.label_name)
+        self.layout_name.addWidget(self.line_edit_name)
+
+        self.label_attachment = QLabel("Vedlegg:")
+
+        self.checkbox_attachment = QCheckBox()
+
+        self.layout_attachment = QHBoxLayout()
+        self.layout_attachment.setAlignment(Qt.AlignLeft)
+        self.layout_attachment.addWidget(self.label_attachment)
+        self.layout_attachment.addWidget(self.checkbox_attachment)
+
+        self.layout_wrapper = QVBoxLayout()
+        self.layout_wrapper.addWidget(self.list_module_choices)
+        self.layout_wrapper.addLayout(self.layout_name)
+        self.layout_wrapper.addLayout(self.layout_attachment)
+        self.layout_wrapper.addLayout(self.layout_buttons)
+
+        self.setLayout(self.layout_wrapper)
diff --git a/soitool/soi.py b/soitool/soi.py
index bfcc1c27ac109859860e2cb8955afe0c4aaaa315..a8c340fc7a670b06a8074224d3eecb2a29459280 100644
--- a/soitool/soi.py
+++ b/soitool/soi.py
@@ -111,6 +111,10 @@ class ModuleLargerThanBinError(Exception):
     """At least one module is too large for the bin during rectpack packing."""
 
 
+class ModuleNameTaken(Exception):
+    """Module name is already taken by an existing module."""
+
+
 class ModuleType(Enum):
     """Enumerate with types of modules."""
 
@@ -309,8 +313,10 @@ class SOI:
         self.algorithm_pack = algorithm_pack
         self.algorithm_sort = algorithm_sort
 
-        # functions to call after reorganization
+        # prepare listener lists: list of functions to call after certain
+        # events happen
         self.reorganization_listeners = []
+        self.new_module_listeners = []
 
         self.reorganize()
 
@@ -322,6 +328,14 @@ class SOI:
         """
         self.reorganization_listeners.append(function)
 
+    def add_new_module_listener(self, function):
+        """Add function to list of functions to be called after added module.
+
+        This is useful for users of this class to handle changes to the SOI.
+        As an example a class displaying an SOI can be updated after changes.
+        """
+        self.new_module_listeners.append(function)
+
     def update_module_widget_position(self, module):
         """Update position of module widget based on meta position.
 
@@ -466,3 +480,44 @@ class SOI:
             corresponding_module["meta"]["y"] = packed_rect_y
             corresponding_module["meta"]["page"] = packed_rect_bin + 1
             self.update_module_widget_position(corresponding_module)
+
+    def module_name_taken(self, name):
+        """Return True if module with name exists, False otherwise."""
+        for module in self.modules + self.attachments:
+            if name == module["meta"]["name"]:
+                return True
+        return False
+
+    def add_module(self, name, widget_implementation, is_attachment=False):
+        """Add module to SOI, reorganize it, and notify listeners.
+
+        This function raises an exception if the given name is taken
+
+        Parameters
+        ----------
+        name : str
+            Name of new module
+        widget_implementation : subclass of ModuleBase
+            The widget implementation of the module.
+        is_attachment : bool
+            True if the module should be added as an attachment. False
+            otherwise
+        """
+        if self.module_name_taken(name):
+            raise ModuleNameTaken
+
+        # NOTE that "x", "y" and "page" under "meta" are not valid until
+        # self.reorganize() has run
+        to_add = {
+            "meta": {"x": 0, "y": 0, "page": 0, "name": name},
+            "widget": widget_implementation,
+        }
+        if is_attachment:
+            self.attachments.append(to_add)
+        else:
+            self.modules.append(to_add)
+
+        self.reorganize()
+        # call listener functions
+        for listener in self.new_module_listeners:
+            listener()
diff --git a/soitool/soi_workspace_widget.py b/soitool/soi_workspace_widget.py
index 4c00e20aaaa6232bf75b7364505741b7bbdb8714..d2c929d84443a27ed08ff11a475de7d472d53ac1 100644
--- a/soitool/soi_workspace_widget.py
+++ b/soitool/soi_workspace_widget.py
@@ -9,12 +9,14 @@ from PySide2.QtWidgets import (
     QVBoxLayout,
     QPushButton,
     QLabel,
+    QDialog,
 )
-from soitool.soi import SOI, ModuleType
+from soitool.soi import SOI, ModuleType, ModuleNameTaken
 from soitool.module_list import ModuleList
 from soitool.inline_editable_soi_view import InlineEditableSOIView
 from soitool.setup_settings import Setup
-from soitool.modules.module_table import TableModule
+from soitool.new_module_dialog import NewModuleDialog, ModulePlaceholder
+from soitool.dialog_wrappers import exec_warning_dialog
 
 
 class SOIWorkspaceWidget(QWidget):
@@ -27,39 +29,7 @@ class SOIWorkspaceWidget(QWidget):
     def __init__(self):
         super().__init__()
 
-        # NOTE
-        # * test modules, just to have something show up on screen
-        # * not valid until reorganize has been run, as the positions must be
-        #   updated
-        initial_modules = [
-            {
-                "widget": TableModule(),
-                "meta": {"x": 0, "y": 0, "page": 1, "name": "Tabell1"},
-            },
-            {
-                "widget": TableModule(),
-                "meta": {"x": 0, "y": 0, "page": 1, "name": "Tabell2"},
-            },
-            {
-                "widget": TableModule(),
-                "meta": {"x": 0, "y": 0, "page": 2, "name": "Tabell3"},
-            },
-        ]
-
-        # NOTE
-        # test attachments, just to have something show up on screen
-        initial_attachments = [
-            {
-                "widget": TableModule(),
-                "meta": {"x": 0, "y": 0, "page": 2, "name": "Tabell1"},
-            }
-        ]
-
-        self.soi = SOI(
-            modules=initial_modules, attachments=initial_attachments
-        )
-
-        self.soi.reorganize()
+        self.soi = SOI()
 
         self.layout_wrapper = QHBoxLayout()
         self.layout_sidebar = QVBoxLayout()
@@ -92,8 +62,68 @@ class SOIWorkspaceWidget(QWidget):
         self.setLayout(self.layout_wrapper)
 
         self.button_setup.clicked.connect(self.open_setup)
+        self.button_new_module.clicked.connect(self.new_module)
 
     def open_setup(self):
         """Open setup_settings."""
         self.popup.setGeometry(150, 150, 200, 200)
         self.popup.exec()  # exec = modal dialog, show = modeless dialog
+
+    def new_module(self):
+        """Add new module to SOI using new_module dialog."""
+        new_module_dialog = NewModuleDialog()
+        new_module_dialog.resize(350, 350)
+        dialogcode = new_module_dialog.exec()
+
+        if dialogcode == QDialog.DialogCode.Accepted:
+
+            # fetch user's choices from the dialog
+            chosen_module = new_module_dialog.list_module_choices.currentItem()
+            module_choice = chosen_module.text()
+            module_name = new_module_dialog.line_edit_name.text()
+            module_widget_implementation = chosen_module.widget_implementation
+            is_attachment = new_module_dialog.checkbox_attachment.isChecked()
+
+            if module_widget_implementation is ModulePlaceholder:
+                exec_warning_dialog(
+                    text="Modulen ble ikke lagt til.",
+                    informative_text="Den valgte modulen er ikke "
+                    "implementert. Modulen er trolig bare valgbar for å fylle "
+                    "ut valgene til flere moduler er implementert.",
+                )
+            else:
+                # no module name means the user expects one to be generated
+                # autogenerated name is not meant to be pretty, it's just meant
+                # to be unique
+                if not module_name:
+                    module_name = "{} {}".format(
+                        module_choice,
+                        str(
+                            len(self.soi.modules)
+                            + len(self.soi.attachments)
+                            + 1
+                        ),
+                    )
+
+                try:
+                    self.soi.add_module(
+                        module_name,
+                        module_widget_implementation(),
+                        is_attachment,
+                    )
+                except ModuleNameTaken:
+                    exec_warning_dialog(
+                        text="Modulen ble ikke lagt til.",
+                        informative_text="Navnet du valgte er allerede i "
+                        "bruk. Modulnavn må være unike. Velg et unikt "
+                        "modulnavn, eller la programmet lage et navn "
+                        "automatisk.",
+                    )
+
+        elif dialogcode == QDialog.DialogCode.Rejected:
+            pass
+        else:
+            raise Exception(
+                f"Unknown QDialog.DialogCode returned from "
+                "NewModuleDialog: '{dialogcode}'"
+            )
diff --git a/test/test_dialog_wrappers.py b/test/test_dialog_wrappers.py
index ba0cbca7f49f16deef915170a8c7be1d8ea3bd9d..d67e398e624b5e8fbec40c30a60e519322a541a5 100644
--- a/test/test_dialog_wrappers.py
+++ b/test/test_dialog_wrappers.py
@@ -1,4 +1,4 @@
-"""Test functions in test_dialog_wrappers.py.
+"""Test functions in dialog_wrappers.py.
 
 Tests are performed by launching the dialogs and inspecting their icons. To
 test the dialogs we need to 'load' our testing function using
@@ -32,7 +32,7 @@ class TestDialogWrappers(unittest.TestCase):
         """Assert icon of current dialog and close it by pressing enter."""
         active_widget = app.activeModalWidget()
         self.assertEqual(icon, active_widget.icon())
-        QTest.keyClick(app.activeModalWidget(), Qt.Key_Enter)
+        QTest.keyClick(active_widget, Qt.Key_Enter)
 
     def test_qmessagebox_wrapper(self):
         """Test the general QMessageBox wrapper."""
diff --git a/test/test_new_module_dialog.py b/test/test_new_module_dialog.py
new file mode 100644
index 0000000000000000000000000000000000000000..e4934fceace445787100dbdbba72dfb4911171cb
--- /dev/null
+++ b/test/test_new_module_dialog.py
@@ -0,0 +1,166 @@
+"""Test dialog in new_module_dialog.py."""
+
+import unittest
+from PySide2 import QtGui
+from PySide2.QtGui import QIcon
+from PySide2.QtWidgets import QApplication, QDialog
+from PySide2.QtCore import QTimer, Qt
+from PySide2.QtTest import QTest
+from soitool.new_module_dialog import NewModuleDialog
+from soitool.modules.module_base import ModuleBase
+
+
+class SimpleTestModule(ModuleBase):
+    """Simple module used to test NewModuleDialog only."""
+
+    name = ""
+
+    def set_pos(self, pos):
+        """Not used."""
+        raise NotImplementedError
+
+    def render_onto_pdf(self):
+        """Not used."""
+        raise NotImplementedError
+
+    def get_size(self):
+        """Not used."""
+        raise NotImplementedError
+
+    @classmethod
+    def get_user_friendly_name(cls):
+        """Get placeholder name."""
+        return cls.name
+
+    @staticmethod
+    def get_icon():
+        """Get standard placeholder icon."""
+        return QIcon("soitool/media/placeholder.png")
+
+
+if isinstance(QtGui.qApp, type(None)):
+    app = QApplication([])
+else:
+    app = QtGui.qApp
+
+
+class TestNewModuleDialog(unittest.TestCase):
+    """TestCase for NewModuleDialog class."""
+
+    def test_starts_up(self):
+        """Test that the dialog can start.
+
+        Also tests whether dialog can be closed with the enter key.
+        """
+        dialog = NewModuleDialog()
+
+        def assert_window_title_of_active_dialog():
+            active_widget = app.activeModalWidget()
+            self.assertEqual("Ny modul", active_widget.windowTitle())
+            QTest.keyClick(active_widget, Qt.Key_Enter)
+
+        QTimer.singleShot(0, assert_window_title_of_active_dialog)
+        dialog.exec_()
+
+    def test_dialog_code(self):
+        """Test that the dialog returns dialog codes as expected."""
+        # prepare all nested functions
+        def press_enter_on_active_dialog():
+            active_widget = app.activeModalWidget()
+            QTest.keyClick(active_widget, Qt.Key_Enter)
+
+        def press_escape_on_active_dialog():
+            active_widget = app.activeModalWidget()
+            QTest.keyClick(active_widget, Qt.Key_Escape)
+
+        def press_add_button_on_active_dialog():
+            active_widget = app.activeModalWidget()
+            QTest.mouseClick(active_widget.button_add, Qt.LeftButton)
+
+        def press_cancel_button_on_active_dialog():
+            active_widget = app.activeModalWidget()
+            QTest.mouseClick(active_widget.button_cancel, Qt.LeftButton)
+
+        # perform tests
+        dialog = NewModuleDialog()
+        QTimer.singleShot(0, press_enter_on_active_dialog)
+        dialogcode = dialog.exec_()
+        self.assertEqual(dialogcode, QDialog.DialogCode.Accepted)
+
+        dialog = NewModuleDialog()
+        QTimer.singleShot(0, press_escape_on_active_dialog)
+        dialogcode = dialog.exec_()
+        self.assertEqual(dialogcode, QDialog.DialogCode.Rejected)
+
+        dialog = NewModuleDialog()
+        QTimer.singleShot(0, press_add_button_on_active_dialog)
+        dialogcode = dialog.exec_()
+        self.assertEqual(dialogcode, QDialog.DialogCode.Accepted)
+
+        dialog = NewModuleDialog()
+        QTimer.singleShot(0, press_cancel_button_on_active_dialog)
+        dialogcode = dialog.exec_()
+        self.assertEqual(dialogcode, QDialog.DialogCode.Rejected)
+
+    def test_full_selection_scenario(self):
+        """Test full usage of the module."""
+        TestModule1 = SimpleTestModule
+        TestModule1.name = "test1"
+        TestModule2 = SimpleTestModule
+        TestModule2.name = "test2"
+        module_choices = [TestModule1, TestModule2]
+
+        module_to_choose = 1
+
+        name_to_input = "myname"
+
+        def perform_selection_and_add():
+            """Perform selection and accept.
+
+            Select second module, give name, check the attachment checkbox and
+            click add.
+            """
+            # below code is based on code written when we were learning Qt:
+            # https://gitlab.stud.iie.ntnu.no/bachelor-paa-bittet/learning/blob/master/gui_nikko/test_widgets.py#L46
+            active_widget = app.activeModalWidget()
+
+            model_index = active_widget.list_module_choices.model().index(
+                module_to_choose, 0
+            )
+            item_point = active_widget.list_module_choices.visualRect(
+                model_index
+            ).center()
+
+            QTest.mouseClick(
+                active_widget.list_module_choices.viewport(),
+                Qt.LeftButton,
+                Qt.NoModifier,
+                item_point,
+            )
+
+            QTest.keyClicks(active_widget.line_edit_name, name_to_input)
+            QTest.mouseClick(active_widget.checkbox_attachment, Qt.LeftButton)
+            QTest.mouseClick(active_widget.button_cancel, Qt.LeftButton)
+
+        dialog = NewModuleDialog(module_choices=module_choices)
+        QTimer.singleShot(0, perform_selection_and_add)
+        dialog.exec()
+
+        # assert dialog was successfully filled
+
+        self.assertEqual(
+            name_to_input, dialog.line_edit_name.text(),
+        )
+        self.assertEqual(
+            True, dialog.checkbox_attachment.isChecked(),
+        )
+
+        module_choice = dialog.list_module_choices.currentItem()
+        self.assertEqual(
+            module_choices[module_to_choose],
+            module_choice.widget_implementation,
+        )
+        self.assertEqual(
+            module_choices[module_to_choose].get_user_friendly_name(),
+            module_choice.text(),
+        )
diff --git a/test/test_soi.py b/test/test_soi.py
index af49960134881835af154a5a5948aec1dd824196..b1223b4b60e10a16aa109abac9b7b4fb49c392a8 100644
--- a/test/test_soi.py
+++ b/test/test_soi.py
@@ -15,7 +15,7 @@ import unittest
 from PySide2.QtWidgets import QApplication, QWidget
 from PySide2 import QtGui
 from PySide2.QtGui import QPalette, QColor
-from soitool.soi import SOI, ModuleLargerThanBinError
+from soitool.soi import SOI, ModuleLargerThanBinError, ModuleNameTaken
 from soitool.modules.module_base import ModuleBase
 
 if isinstance(QtGui.qApp, type(None)):
@@ -293,3 +293,64 @@ class TestSOI(unittest.TestCase):
         self.assertEqual(
             expected_y, self.soi.modules[0]["widget"].pos().y(),
         )
+
+    def test_add_module(self):
+        """Test add module and reorganize and add module listeners."""
+        reorganize_listener_called = False
+        new_module_listener_called = False
+
+        def listener_reorganize():
+            # nonlocal is required to access variable in nesting function's
+            # scope
+            nonlocal reorganize_listener_called
+            reorganize_listener_called = True
+
+        def listener_new_module():
+            # nonlocal is required to access variable in nesting function's
+            # scope
+            nonlocal new_module_listener_called
+            new_module_listener_called = True
+
+        self.soi.add_reorganization_listener(listener_reorganize)
+        self.soi.add_reorganization_listener(listener_new_module)
+
+        module_name = "add module test name"
+        self.soi.add_module(module_name, TestModule("purple", 100, 100), True)
+        self.assertTrue(self.soi.module_name_taken(module_name))
+
+        self.assertTrue(reorganize_listener_called)
+        self.assertTrue(new_module_listener_called)
+
+    def test_add_module_duplicate_name(self):
+        """Test adding module with duplicate name raises exception."""
+        # Using already added module TEST_MODULES[0] to guarantee name exists
+        self.assertRaises(
+            ModuleNameTaken,
+            lambda: self.soi.add_module(
+                TEST_MODULES[0]["meta"]["name"],
+                TestModule("purple", 100, 100),
+                False,
+            ),
+        )
+        self.assertRaises(
+            ModuleNameTaken,
+            lambda: self.soi.add_module(
+                TEST_MODULES[0]["meta"]["name"],
+                TestModule("purple", 100, 100),
+                True,
+            ),
+        )
+
+        # test that unique name doesn't raise exception
+        try:
+            self.soi.add_module(
+                "unique name", TestModule("purple", 100, 100), True
+            )
+        except ModuleNameTaken:
+            self.fail("Exception should not be raised when name is unique")
+        try:
+            self.soi.add_module(
+                "another unique name", TestModule("purple", 100, 100), False
+            )
+        except ModuleNameTaken:
+            self.fail("Exception should not be raised when name is unique")