diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 8c5d59181d..9880662e63 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -19,6 +19,23 @@ from .version import inventreeVersion, inventreeInstanceName
 from .settings import MEDIA_URL, STATIC_URL
 
 
+def generateTestKey(test_name):
+    """
+    Generate a test 'key' for a given test name.
+    This must not have illegal chars as it will be used for dict lookup in a template.
+    
+    Tests must be named such that they will have unique keys.
+    """
+
+    key = test_name.strip().lower()
+    key = key.replace(" ", "")
+
+    # Remove any characters that cannot be used to represent a variable
+    key = re.sub(r'[^a-zA-Z0-9]', '', key)
+
+    return key
+
+
 def getMediaUrl(filename):
     """
     Return the qualified access path for the given file,
diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py
index 5ec2e2945d..d0ed73f27a 100644
--- a/InvenTree/InvenTree/models.py
+++ b/InvenTree/InvenTree/models.py
@@ -53,6 +53,9 @@ class InvenTreeAttachment(models.Model):
 
         return "attachments"
 
+    def __str__(self):
+        return os.path.basename(self.attachment.name)
+
     attachment = models.FileField(upload_to=rename_attachment,
                                   help_text=_('Select file to attach'))
 
diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py
index fc5727bd9d..de1b7d1fae 100644
--- a/InvenTree/part/admin.py
+++ b/InvenTree/part/admin.py
@@ -12,6 +12,7 @@ from .models import PartCategory, Part
 from .models import PartAttachment, PartStar
 from .models import BomItem
 from .models import PartParameterTemplate, PartParameter
+from .models import PartTestTemplate
 
 from stock.models import StockLocation
 from company.models import SupplierPart
@@ -126,6 +127,11 @@ class PartStarAdmin(admin.ModelAdmin):
     list_display = ('part', 'user')
 
 
+class PartTestTemplateAdmin(admin.ModelAdmin):
+
+    list_display = ('part', 'test_name', 'required')
+
+
 class BomItemResource(ModelResource):
     """ Class for managing BomItem data import/export """
 
@@ -202,3 +208,4 @@ admin.site.register(PartStar, PartStarAdmin)
 admin.site.register(BomItem, BomItemAdmin)
 admin.site.register(PartParameterTemplate, ParameterTemplateAdmin)
 admin.site.register(PartParameter, ParameterAdmin)
+admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 84ff83af0a..1b672d4c68 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -19,7 +19,7 @@ from django.urls import reverse
 
 from .models import Part, PartCategory, BomItem, PartStar
 from .models import PartParameter, PartParameterTemplate
-from .models import PartAttachment
+from .models import PartAttachment, PartTestTemplate
 
 from . import serializers as part_serializers
 
@@ -120,6 +120,52 @@ class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
     ]
 
 
+class PartTestTemplateList(generics.ListCreateAPIView):
+    """
+    API endpoint for listing (and creating) a PartTestTemplate.
+    """
+
+    queryset = PartTestTemplate.objects.all()
+    serializer_class = part_serializers.PartTestTemplateSerializer
+
+    def filter_queryset(self, queryset):
+        """
+        Filter the test list queryset.
+
+        If filtering by 'part', we include results for any parts "above" the specified part.
+        """
+
+        queryset = super().filter_queryset(queryset)
+
+        params = self.request.query_params
+
+        part = params.get('part', None)
+
+        # Filter by part
+        if part:
+            try:
+                part = Part.objects.get(pk=part)
+                queryset = queryset.filter(part__in=part.get_ancestors(include_self=True))
+            except (ValueError, Part.DoesNotExist):
+                pass
+
+        # Filter by 'required' status
+        required = params.get('required', None)
+
+        if required is not None:
+            queryset = queryset.filter(required=required)
+
+        return queryset
+
+    permission_classes = [permissions.IsAuthenticated]
+
+    filter_backends = [
+        DjangoFilterBackend,
+        filters.OrderingFilter,
+        filters.SearchFilter,
+    ]
+
+
 class PartThumbs(generics.ListAPIView):
     """ API endpoint for retrieving information on available Part thumbnails """
 
@@ -635,8 +681,13 @@ part_api_urls = [
         url(r'^$', CategoryList.as_view(), name='api-part-category-list'),
     ])),
 
+    # Base URL for PartTestTemplate API endpoints
+    url(r'^test-template/', include([
+        url(r'^$', PartTestTemplateList.as_view(), name='api-part-test-template-list'),
+    ])),
+
     # Base URL for PartAttachment API endpoints
-    url(r'attachment/', include([
+    url(r'^attachment/', include([
         url(r'^$', PartAttachmentList.as_view(), name='api-part-attachment-list'),
     ])),
     
diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml
index 035049fe81..76a073261b 100644
--- a/InvenTree/part/fixtures/part.yaml
+++ b/InvenTree/part/fixtures/part.yaml
@@ -106,6 +106,7 @@
     name: 'Chair Template'
     description: 'A chair'
     is_template: True
+    trackable: true
     category: 7
     tree_id: 1
     level: 0
@@ -117,6 +118,7 @@
   fields:
     name: 'Blue Chair'
     variant_of: 10000
+    trackable: true
     category: 7
     tree_id: 1
     level: 0
@@ -128,6 +130,7 @@
   fields:
     name: 'Red chair'
     variant_of: 10000
+    trackable: true
     category: 7
     tree_id: 1
     level: 0
@@ -140,6 +143,7 @@
     name: 'Green chair'
     variant_of: 10000
     category: 7
+    trackable: true
     tree_id: 1
     level: 0
     lft: 0
@@ -150,7 +154,8 @@
   fields:
     name: 'Green chair variant'
     variant_of: 10003
-    category: 
+    category: 7
+    trackable: true
     tree_id: 1
     level: 0
     lft: 0
diff --git a/InvenTree/part/fixtures/test_templates.yaml b/InvenTree/part/fixtures/test_templates.yaml
new file mode 100644
index 0000000000..f939dea0c1
--- /dev/null
+++ b/InvenTree/part/fixtures/test_templates.yaml
@@ -0,0 +1,39 @@
+# Tests for the top-level "chair" part
+- model: part.parttesttemplate
+  fields:
+    part: 10000
+    test_name: Test strength of chair
+
+- model: part.parttesttemplate
+  fields:
+    part: 10000
+    test_name: Apply paint
+
+- model: part.parttesttemplate
+  fields:
+    part: 10000
+    test_name: Sew cushion
+
+- model: part.parttesttemplate
+  fields:
+    part: 10000
+    test_name: Attach legs
+
+- model: part.parttesttemplate
+  fields:
+    part: 10000
+    test_name: Record weight
+    required: false
+
+# Add some tests for one of the variants
+- model: part.parttesttemplate
+  fields:
+    part: 10003
+    test_name: Check that chair is green
+    required: true
+
+- model: part.parttesttemplate
+  fields:
+    part: 10004
+    test_name: Check that chair is especially green
+    required: False
\ No newline at end of file
diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index 4d70ab927a..a276d62c54 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -15,6 +15,7 @@ from django.utils.translation import ugettext as _
 from .models import Part, PartCategory, PartAttachment
 from .models import BomItem
 from .models import PartParameterTemplate, PartParameter
+from .models import PartTestTemplate
 
 from common.models import Currency
 
@@ -29,6 +30,19 @@ class PartImageForm(HelperForm):
         ]
 
 
+class EditPartTestTemplateForm(HelperForm):
+    """ Class for creating / editing a PartTestTemplate object """
+
+    class Meta:
+        model = PartTestTemplate
+
+        fields = [
+            'part',
+            'test_name',
+            'required'
+        ]
+
+
 class BomExportForm(forms.Form):
     """ Simple form to let user set BOM export options,
     before exporting a BOM (bill of materials) file.
@@ -125,13 +139,11 @@ class EditPartForm(HelperForm):
             'revision',
             'keywords',
             'variant_of',
-            'is_template',
             'link',
             'default_location',
             'default_supplier',
             'units',
             'minimum_stock',
-            'active',
         ]
 
 
diff --git a/InvenTree/part/migrations/0040_parttesttemplate.py b/InvenTree/part/migrations/0040_parttesttemplate.py
new file mode 100644
index 0000000000..45e270c88c
--- /dev/null
+++ b/InvenTree/part/migrations/0040_parttesttemplate.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.5 on 2020-05-17 03:26
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('part', '0039_auto_20200515_1127'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PartTestTemplate',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('test_name', models.CharField(help_text='Enter a name for the test', max_length=100, verbose_name='Test name')),
+                ('required', models.BooleanField(default=True, help_text='Is this test required to pass?', verbose_name='Required')),
+                ('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='test_templates', to='part.Part')),
+            ],
+        ),
+    ]
diff --git a/InvenTree/part/migrations/0041_auto_20200517_0348.py b/InvenTree/part/migrations/0041_auto_20200517_0348.py
new file mode 100644
index 0000000000..ff24313193
--- /dev/null
+++ b/InvenTree/part/migrations/0041_auto_20200517_0348.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.0.5 on 2020-05-17 03:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('part', '0040_parttesttemplate'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='parttesttemplate',
+            name='part',
+            field=models.ForeignKey(limit_choices_to={'trackable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='test_templates', to='part.Part'),
+        ),
+    ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 7639b9c25b..d37d666be4 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -990,6 +990,30 @@ class Part(MPTTModel):
 
         self.save()
 
+    def getTestTemplates(self, required=None, include_parent=True):
+        """
+        Return a list of all test templates associated with this Part.
+        These are used for validation of a StockItem.
+
+        args:
+            required: Set to True or False to filter by "required" status
+            include_parent: Set to True to traverse upwards
+        """
+
+        if include_parent:
+            tests = PartTestTemplate.objects.filter(part__in=self.get_ancestors(include_self=True))
+        else:
+            tests = self.test_templates
+
+        if required is not None:
+            tests = tests.filter(required=required)
+
+        return tests
+    
+    def getRequiredTests(self):
+        # Return the tests which are required by this part
+        return self.getTestTemplates(required=True)
+
     @property
     def attachment_count(self):
         """ Count the number of attachments for this part.
@@ -1109,6 +1133,88 @@ class PartStar(models.Model):
         unique_together = ['part', 'user']
 
 
+class PartTestTemplate(models.Model):
+    """
+    A PartTestTemplate defines a 'template' for a test which is required to be run
+    against a StockItem (an instance of the Part).
+
+    The test template applies "recursively" to part variants, allowing tests to be
+    defined in a heirarchy.
+
+    Test names are simply strings, rather than enforcing any sort of structure or pattern.
+    It is up to the user to determine what tests are defined (and how they are run).
+
+    To enable generation of unique lookup-keys for each test, there are some validation tests
+    run on the model (refer to the validate_unique function).
+    """
+
+    def save(self, *args, **kwargs):
+
+        self.clean()
+
+        super().save(*args, **kwargs)
+
+    def clean(self):
+
+        self.test_name = self.test_name.strip()
+
+        self.validate_unique()
+        super().clean()
+
+    def validate_unique(self, exclude=None):
+        """
+        Test that this test template is 'unique' within this part tree.
+        """
+
+        if not self.part.trackable:
+            raise ValidationError({
+                'part': _('Test templates can only be created for trackable parts')
+            })
+
+        # Get a list of all tests "above" this one
+        tests = PartTestTemplate.objects.filter(
+            part__in=self.part.get_ancestors(include_self=True)
+        )
+
+        # If this item is already in the database, exclude it from comparison!
+        if self.pk is not None:
+            tests = tests.exclude(pk=self.pk)
+
+        key = self.key
+
+        for test in tests:
+            if test.key == key:
+                raise ValidationError({
+                    'test_name': _("Test with this name already exists for this part")
+                })
+
+        super().validate_unique(exclude)
+
+    @property
+    def key(self):
+        """ Generate a key for this test """
+        return helpers.generateTestKey(self.test_name)
+
+    part = models.ForeignKey(
+        Part,
+        on_delete=models.CASCADE,
+        related_name='test_templates',
+        limit_choices_to={'trackable': True},
+    )
+
+    test_name = models.CharField(
+        blank=False, max_length=100,
+        verbose_name=_("Test name"),
+        help_text=_("Enter a name for the test")
+    )
+
+    required = models.BooleanField(
+        default=True,
+        verbose_name=_("Required"),
+        help_text=_("Is this test required to pass?")
+    )
+
+
 class PartParameterTemplate(models.Model):
     """
     A PartParameterTemplate provides a template for key:value pairs for extra
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 74005dd5af..396f19ea58 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -10,6 +10,7 @@ from .models import PartCategory
 from .models import BomItem
 from .models import PartParameter, PartParameterTemplate
 from .models import PartAttachment
+from .models import PartTestTemplate
 
 from decimal import Decimal
 
@@ -56,6 +57,22 @@ class PartAttachmentSerializer(InvenTreeModelSerializer):
         ]
 
 
+class PartTestTemplateSerializer(InvenTreeModelSerializer):
+    """
+    Serializer for the PartTestTemplate class
+    """
+
+    class Meta:
+        model = PartTestTemplate
+
+        fields = [
+            'pk',
+            'part',
+            'test_name',
+            'required'
+        ]
+
+
 class PartThumbSerializer(serializers.Serializer):
     """
     Serializer for the 'image' field of the Part model.
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index 6d7400d4da..e9e09959fb 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -128,6 +128,15 @@
             <td><i>{% trans "Part is not a virtual part" %}</i></td>
             {% endif %}
         </tr>
+        <tr>
+            <td><b>{% trans "Template" %}</b></td>
+            <td>{% include "slide.html" with state=part.is_template field='is_template' %}</td>
+            {% if part.is_template %}
+            <td>{% trans "Part is a template part (variants can be made from this part)" %}</td>
+            {% else %}
+            <td><i>{% trans "Part is not a template part" %}</i></td>
+            {% endif %}
+        </tr>
         <tr>
             <td><b>{% trans "Assembly" %}</b></td>
             <td>{% include "slide.html" with state=part.assembly field='assembly' %}</td>   
@@ -173,6 +182,15 @@
             <td><i>{% trans "Part cannot be sold to customers" %}</i></td>
             {% endif %}
         </tr>
+        <tr>
+            <td><b>{% trans "Active" %}</b></td>
+            <td>{% include "slide.html" with state=part.active field='active' %}</td>
+            {% if part.active %}
+            <td>{% trans "Part is active" %}</td>
+            {% else %}
+            <td><i>{% trans "Part is not active" %}</i></td>
+            {% endif %}
+        </tr>
     </table>
     </div>
 </div>
@@ -196,7 +214,7 @@
         data[field] = checked;
 
         // Update the particular field
-        inventreePut("/api/part/{{ part.id }}/",
+        inventreePut("{% url 'api-part-detail' part.id %}",
             data,
             {
                 method: 'PATCH',
diff --git a/InvenTree/part/templates/part/part_tests.html b/InvenTree/part/templates/part/part_tests.html
new file mode 100644
index 0000000000..5728f2205e
--- /dev/null
+++ b/InvenTree/part/templates/part/part_tests.html
@@ -0,0 +1,75 @@
+{% extends "part/part_base.html" %}
+{% load static %}
+{% load i18n %}
+{% block details %}
+
+{% include 'part/tabs.html' with tab='tests' %}
+
+<h4>{% trans "Part Test Templates" %}</h4>
+<hr>
+
+<div id='button-toolbar'>
+    <div class='button-toolbar container-fluid' style="float: right;">
+        <div class='btn-group' role='group'>
+            <button type='button' class='btn btn-success' id='add-test-template'>{% trans "Add Test Template" %}</button>
+        </div>
+        <div class='filter-list' id='filter-list-parttests'>
+            <!-- Empty div -->
+        </div>
+    </div>
+</div>
+
+<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='test-template-table'></table>
+
+{% endblock %}
+
+{% block js_ready %}
+{{ block.super }}
+
+loadPartTestTemplateTable(
+    $("#test-template-table"),
+    {
+        part: {{ part.pk }},
+        params: {
+            part: {{ part.pk }},
+        }
+    }
+);
+
+function reloadTable() {
+    $("#test-template-table").bootstrapTable("refresh");
+}
+
+$("#add-test-template").click(function() {
+    launchModalForm(
+        "{% url 'part-test-template-create' %}",
+        {
+            data: {
+                part: {{ part.id }},
+            },
+            success: reloadTable,
+        }
+    );
+});
+
+$("#test-template-table").on('click', '.button-test-edit', function() {
+    var button = $(this);
+
+    var url = `/part/test-template/${button.attr('pk')}/edit/`;
+
+    launchModalForm(url, {
+        success: reloadTable,
+    });
+});
+
+$("#test-template-table").on('click', '.button-test-delete', function() {
+    var button = $(this);
+
+    var url = `/part/test-template/${button.attr('pk')}/delete/`;
+
+    launchModalForm(url, {
+        success: reloadTable,
+    });
+});
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html
index b47ebfe329..28aa2cbb4d 100644
--- a/InvenTree/part/templates/part/tabs.html
+++ b/InvenTree/part/templates/part/tabs.html
@@ -48,7 +48,9 @@
         <a href="{% url 'part-sales-orders' part.id %}">{% trans "Sales Orders" %} <span class='badge'>{{ part.sales_orders|length }}</span></a>
     </li>
     {% endif %}
-    {% if 0 and part.trackable %}
+    {% if part.trackable %}
+    {% if 0 %}
+    <!-- TODO - Add the 'tracking' tab back in -->
     <li{% ifequal tab 'track' %} class="active"{% endifequal %}>
         <a href="{% url 'part-track' part.id %}">{% trans "Tracking" %}
         {% if parts.serials.all|length > 0 %}
@@ -56,6 +58,12 @@
         {% endif %}
     </a></li>
     {% endif %}
+    <li{% ifequal tab 'tests' %} class='active'{% endifequal %}>
+        <a href='{% url "part-test-templates" part.id %}'>{% trans "Tests" %}
+            {% if part.getTestTemplates.count > 0 %}<span class='badge'>{{ part.getTestTemplates.count }}</span>{% endif %}
+        </a>
+    </li>
+    {% endif %}
     <li{% ifequal tab 'attachments' %} class="active"{% endifequal %}>
         <a href="{% url 'part-attachments' part.id %}">{% trans "Attachments" %} {% if part.attachment_count > 0 %}<span class="badge">{{ part.attachment_count }}</span>{% endif %}</a>
     </li>
diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py
index 61e7f4ab32..9fcf98d712 100644
--- a/InvenTree/part/test_api.py
+++ b/InvenTree/part/test_api.py
@@ -16,6 +16,7 @@ class PartAPITest(APITestCase):
         'part',
         'location',
         'bom',
+        'test_templates',
     ]
 
     def setUp(self):
@@ -159,3 +160,56 @@ class PartAPITest(APITestCase):
         data['part'] = 2
         data['sub_part'] = 2
         response = self.client.post(url, data, format='json')
+
+    def test_test_templates(self):
+
+        url = reverse('api-part-test-template-list')
+
+        # List ALL items
+        response = self.client.get(url)
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 7)
+
+        # Request for a particular part
+        response = self.client.get(url, data={'part': 10000})
+        self.assertEqual(len(response.data), 5)
+
+        response = self.client.get(url, data={'part': 10004})
+        self.assertEqual(len(response.data), 7)
+
+        # Try to post a new object (should succeed)
+        response = self.client.post(
+            url,
+            data={
+                'part': 10000,
+                'test_name': 'New Test',
+                'required': True,
+            },
+            format='json',
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        # Try to post a new test with the same name (should fail)
+        response = self.client.post(
+            url,
+            data={
+                'part': 10004,
+                'test_name': "   newtest"
+            },
+            format='json',
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+        # Try to post a new test against a non-trackable part (should fail)
+        response = self.client.post(
+            url,
+            data={
+                'part': 1,
+                'test_name': 'A simple test',
+            }
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py
index 99d4bce796..40f0f113a0 100644
--- a/InvenTree/part/test_category.py
+++ b/InvenTree/part/test_category.py
@@ -88,9 +88,9 @@ class CategoryTest(TestCase):
 
         self.assertEqual(self.electronics.partcount(), 3)
 
-        self.assertEqual(self.mechanical.partcount(), 8)
-        self.assertEqual(self.mechanical.partcount(active=True), 7)
-        self.assertEqual(self.mechanical.partcount(False), 6)
+        self.assertEqual(self.mechanical.partcount(), 9)
+        self.assertEqual(self.mechanical.partcount(active=True), 8)
+        self.assertEqual(self.mechanical.partcount(False), 7)
 
         self.assertEqual(self.electronics.item_count, self.electronics.partcount())
 
diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py
index 622f0af547..de2edcec98 100644
--- a/InvenTree/part/test_part.py
+++ b/InvenTree/part/test_part.py
@@ -5,10 +5,11 @@
 from __future__ import unicode_literals
 
 from django.test import TestCase
+from django.core.exceptions import ValidationError
 
 import os
 
-from .models import Part
+from .models import Part, PartTestTemplate
 from .models import rename_part_image, match_part_names
 from .templatetags import inventree_extras
 
@@ -105,3 +106,61 @@ class PartTest(TestCase):
         matches = match_part_names('M2x5 LPHS')
 
         self.assertTrue(len(matches) > 0)
+
+
+class TestTemplateTest(TestCase):
+
+    fixtures = [
+        'category',
+        'part',
+        'location',
+        'test_templates',
+    ]
+
+    def test_template_count(self):
+
+        chair = Part.objects.get(pk=10000)
+
+        # Tests for the top-level chair object (nothing above it!)
+        self.assertEqual(chair.test_templates.count(), 5)
+        self.assertEqual(chair.getTestTemplates().count(), 5)
+        self.assertEqual(chair.getTestTemplates(required=True).count(), 4)
+        self.assertEqual(chair.getTestTemplates(required=False).count(), 1)
+
+        # Test the lowest-level part which has more associated tests
+        variant = Part.objects.get(pk=10004)
+
+        self.assertEqual(variant.getTestTemplates().count(), 7)
+        self.assertEqual(variant.getTestTemplates(include_parent=False).count(), 1)
+        self.assertEqual(variant.getTestTemplates(required=True).count(), 5)
+
+    def test_uniqueness(self):
+        # Test names must be unique for this part and also parts above
+
+        variant = Part.objects.get(pk=10004)
+
+        with self.assertRaises(ValidationError):
+            PartTestTemplate.objects.create(
+                part=variant,
+                test_name='Record weight'
+            )
+
+        with self.assertRaises(ValidationError):
+            PartTestTemplate.objects.create(
+                part=variant,
+                test_name='Check that chair is especially green'
+            )
+
+        # Also should fail if we attempt to create a test that would generate the same key
+        with self.assertRaises(ValidationError):
+            PartTestTemplate.objects.create(
+                part=variant,
+                test_name='ReCoRD       weiGHT  '
+            )
+
+        # But we should be able to create a new one!
+        n = variant.getTestTemplates().count()
+
+        PartTestTemplate.objects.create(part=variant, test_name='A Sample Test')
+
+        self.assertEqual(variant.getTestTemplates().count(), n + 1)
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index 10db202fb9..7d5c279e75 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -52,6 +52,7 @@ part_detail_urls = [
     url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'),
     url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
     url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'),
+    url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
     url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
     url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),
     url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'),
@@ -107,6 +108,13 @@ part_urls = [
     # Part attachments
     url(r'^attachment/', include(part_attachment_urls)),
 
+    # Part test templates
+    url(r'^test-template/', include([
+        url(r'^new/', views.PartTestTemplateCreate.as_view(), name='part-test-template-create'),
+        url(r'^(?P<pk>\d+)/edit/', views.PartTestTemplateEdit.as_view(), name='part-test-template-edit'),
+        url(r'^(?P<pk>\d+)/delete/', views.PartTestTemplateDelete.as_view(), name='part-test-template-delete'),
+    ])),
+
     # Part parameters
     url(r'^parameter/', include(part_parameter_urls)),
 
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index ce2fc415be..eda1db923b 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -25,6 +25,7 @@ from .models import PartCategory, Part, PartAttachment
 from .models import PartParameterTemplate, PartParameter
 from .models import BomItem
 from .models import match_part_names
+from .models import PartTestTemplate
 
 from common.models import Currency, InvenTreeSetting
 from company.models import SupplierPart
@@ -149,6 +150,55 @@ class PartAttachmentDelete(AjaxDeleteView):
         }
 
 
+class PartTestTemplateCreate(AjaxCreateView):
+    """ View for creating a PartTestTemplate """
+
+    model = PartTestTemplate
+    form_class = part_forms.EditPartTestTemplateForm
+    ajax_form_title = _("Create Test Template")
+    
+    def get_initial(self):
+
+        initials = super().get_initial()
+
+        try:
+            part_id = self.request.GET.get('part', None)
+            initials['part'] = Part.objects.get(pk=part_id)
+        except (ValueError, Part.DoesNotExist):
+            pass
+
+        return initials
+
+    def get_form(self):
+
+        form = super().get_form()
+        form.fields['part'].widget = HiddenInput()
+
+        return form
+
+
+class PartTestTemplateEdit(AjaxUpdateView):
+    """ View for editing a PartTestTemplate """
+
+    model = PartTestTemplate
+    form_class = part_forms.EditPartTestTemplateForm
+    ajax_form_title = _("Edit Test Template")
+
+    def get_form(self):
+
+        form = super().get_form()
+        form.fields['part'].widget = HiddenInput()
+
+        return form
+
+
+class PartTestTemplateDelete(AjaxDeleteView):
+    """ View for deleting a PartTestTemplate """
+
+    model = PartTestTemplate
+    ajax_form_title = _("Delete Test Template")
+
+
 class PartSetCategory(AjaxUpdateView):
     """ View for settings the part category for multiple parts at once """
 
@@ -251,7 +301,6 @@ class MakePartVariant(AjaxCreateView):
         form = super(AjaxCreateView, self).get_form()
 
         # Hide some variant-related fields
-        form.fields['is_template'].widget = HiddenInput()
         form.fields['variant_of'].widget = HiddenInput()
 
         return form
diff --git a/InvenTree/stock/fixtures/stock_tests.yaml b/InvenTree/stock/fixtures/stock_tests.yaml
index 9a82b6ada7..8207ccaa8c 100644
--- a/InvenTree/stock/fixtures/stock_tests.yaml
+++ b/InvenTree/stock/fixtures/stock_tests.yaml
@@ -28,4 +28,41 @@
     test: "Temperature Test"
     result: True
     date: 2020-05-17
-    notes: 'Passed temperature test by making it cooler'
\ No newline at end of file
+    notes: 'Passed temperature test by making it cooler'
+
+- model: stock.stockitemtestresult
+  fields:
+    stock_item: 522
+    test: 'applypaint'
+    result: True
+    date: 2020-05-17
+
+- model: stock.stockitemtestresult
+  fields:
+    stock_item: 522
+    test: 'applypaint'
+    result: False
+    date: 2020-05-18
+
+- model: stock.stockitemtestresult
+  fields:
+    stock_item: 522
+    test: 'Attach Legs'
+    result: True
+    date: 2020-05-17
+
+- model: stock.stockitemtestresult
+  fields:
+    stock_item: 522
+    test: 'Check that chair is GreEn   '
+    result: True
+    date: 2020-05-17
+
+- model: stock.stockitemtestresult
+  pk: 12345
+  fields:
+    stock_item: 522
+    test: 'test strength of chair'
+    result: False
+    value: 100kg
+    date: 2020-05-17
\ No newline at end of file
diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index 0d543b1315..9576447997 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -46,6 +46,7 @@ class EditStockItemTestResultForm(HelperForm):
             'test',
             'result',
             'value',
+            'attachment',
             'notes',
         ]
 
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index a73c7e0a76..c50a58bea9 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -276,10 +276,6 @@ class StockItem(MPTTModel):
                     # Serial numbered items cannot be deleted on depletion
                     self.delete_on_deplete = False
 
-                # A template part cannot be instantiated as a StockItem
-                if self.part.is_template:
-                    raise ValidationError({'part': _('Stock item cannot be created for a template Part')})
-
         except PartModels.Part.DoesNotExist:
             # This gets thrown if self.supplier_part is null
             # TODO - Find a test than can be perfomed...
@@ -962,10 +958,57 @@ class StockItem(MPTTModel):
         result_map = {}
 
         for result in results:
-            result_map[result.test] = result
+            key = helpers.generateTestKey(result.test)
+            result_map[key] = result
 
         return result_map
 
+    def requiredTestStatus(self):
+        """
+        Return the status of the tests required for this StockItem.
+
+        return:
+            A dict containing the following items:
+            - total: Number of required tests
+            - passed: Number of tests that have passed
+            - failed: Number of tests that have failed
+        """
+
+        # All the tests required by the part object
+        required = self.part.getRequiredTests()
+
+        results = self.testResultMap()
+
+        total = len(required)
+        passed = 0
+        failed = 0
+
+        for test in required:
+            key = helpers.generateTestKey(test.test_name)
+
+            if key in results:
+                result = results[key]
+
+                if result.result:
+                    passed += 1
+                else:
+                    failed += 1
+
+        return {
+            'total': total,
+            'passed': passed,
+            'failed': failed,
+        }
+
+    def hasRequiredTests(self):
+        return self.part.getRequiredTests().count() > 0
+
+    def passedAllRequiredTests(self):
+
+        status = self.requiredTestStatus()
+
+        return status['passed'] >= status['total']
+
 
 @receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
 def before_delete_stock_item(sender, instance, using, **kwargs):
diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html
index 4e2d6b7439..782e51a2b8 100644
--- a/InvenTree/stock/templates/stock/item_base.html
+++ b/InvenTree/stock/templates/stock/item_base.html
@@ -15,6 +15,12 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
 {% block pre_content %}
 {% include 'stock/loc_link.html' with location=item.location %}
 
+{% if item.hasRequiredTests and not item.passedAllRequiredTests %}
+<div class='alert alert-block alert-danger'>
+    {% trans "This stock item has not passed all required tests" %}
+</div>
+{% endif %}
+
 {% for allocation in item.sales_order_allocations.all %}
 <div class='alert alert-block alert-info'>
     {% trans "This stock item is allocated to Sales Order" %} <a href="{% url 'so-detail' allocation.line.order.id %}"><b>#{{ allocation.line.order.reference }}</b></a> ({% trans "Quantity" %}: {% decimal allocation.quantity %})
@@ -221,6 +227,13 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
         <td>{% trans "Status" %}</td>
         <td>{% stock_status_label item.status %}</td>
     </tr>
+    {% if item.hasRequiredTests %}
+    <tr>
+        <td><span class='fas fa-vial'></span></td>
+        <td>{% trans "Tests" %}</td>
+        <td>{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}</td>
+    </tr>
+    {% endif %}
 </table>
 {% endblock %}
 
diff --git a/InvenTree/stock/templates/stock/item_tests.html b/InvenTree/stock/templates/stock/item_tests.html
index 6621637f1f..6a69d55eb9 100644
--- a/InvenTree/stock/templates/stock/item_tests.html
+++ b/InvenTree/stock/templates/stock/item_tests.html
@@ -7,21 +7,21 @@
 
 {% include "stock/tabs.html" with tab='tests' %}
 
-<h4>{% trans "Test Results" %}</h4>
+<h4>{% trans "Test Data" %}</h4>
 <hr>
 
 <div id='button-toolbar'>
     <div class='button-toolbar container-fluid' style="float: right;">
         <div class='btn-group' role='group'>
-            <button type='button' class='btn btn-success' id='add-test-result'>{% trans "Add Test Result" %}</button>
+            <button type='button' class='btn btn-success' id='add-test-result'>{% trans "Add Test Data" %}</button>
         </div>
         <div class='filter-list' id='filter-list-stocktests'>
             <!-- Empty div -->
         </div>
     </div>
 </div>
-
-<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='test-result-table'></table>
+        
+<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='test-result-table'></table>                
 
 {% endblock %}
 
@@ -30,16 +30,14 @@
 
 loadStockTestResultsTable(
     $("#test-result-table"), {
-        params: {
-            stock_item: {{ item.id }},
-            user_detail: true,
-            attachment_detail: true,
-        },
+        part: {{ item.part.id }},
+        stock_item: {{ item.id }},
     }
 );
 
 function reloadTable() {
-    $("#test-result-table").bootstrapTable("refresh");
+    location.reload();
+    //$("#test-result-table").bootstrapTable("refresh");
 }
 
 $("#add-test-result").click(function() {
@@ -53,6 +51,22 @@ $("#add-test-result").click(function() {
     );
 });
 
+$("#test-result-table").on('click', '.button-test-add', function() {
+    var button = $(this);
+
+    var test_name = button.attr('pk');
+
+    launchModalForm(
+        "{% url 'stock-item-test-create' %}", {
+            data: {
+                stock_item: {{ item.id }},
+                test: test_name
+            },
+            success: reloadTable,
+        }
+    );
+});
+
 $("#test-result-table").on('click', '.button-test-edit', function() {
     var button = $(this);
 
diff --git a/InvenTree/stock/templates/stock/tabs.html b/InvenTree/stock/templates/stock/tabs.html
index 5ec75ddc28..1e6e9ddb3b 100644
--- a/InvenTree/stock/templates/stock/tabs.html
+++ b/InvenTree/stock/templates/stock/tabs.html
@@ -10,7 +10,7 @@
     {% if item.part.trackable %}
     <li{% if tab == 'tests' %} class='active'{% endif %}>
         <a href="{% url 'stock-item-test-results' item.id %}">
-            {% trans "Test Results" %}
+            {% trans "Test Data" %}
             {% if item.test_results.count > 0 %}<span class='badge'>{{ item.test_results.count }}</span>{% endif %}
         </a>
     </li>
diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py
index 8fce455a97..045cbe164e 100644
--- a/InvenTree/stock/tests.py
+++ b/InvenTree/stock/tests.py
@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
 
 from .models import StockLocation, StockItem, StockItemTracking
+from .models import StockItemTestResult
 from part.models import Part
 
 
@@ -15,6 +16,7 @@ class StockTest(TestCase):
     fixtures = [
         'category',
         'part',
+        'test_templates',
         'location',
         'stock',
         'stock_tests',
@@ -429,5 +431,30 @@ class TestResultTest(StockTest):
 
         self.assertEqual(len(result_map), 3)
 
-        for test in ['Firmware Version', 'Settings Checksum', 'Temperature Test']:
+        # Keys are all lower-case and do not contain spaces
+        for test in ['firmwareversion', 'settingschecksum', 'temperaturetest']:
             self.assertIn(test, result_map.keys())
+
+    def test_test_results(self):
+        item = StockItem.objects.get(pk=522)
+
+        status = item.requiredTestStatus()
+
+        self.assertEqual(status['total'], 5)
+        self.assertEqual(status['passed'], 3)
+        self.assertEqual(status['failed'], 1)
+
+        self.assertFalse(item.passedAllRequiredTests())
+
+        # Add some new test results to make it pass!
+        test = StockItemTestResult.objects.get(pk=12345)
+        test.result = True
+        test.save()
+
+        StockItemTestResult.objects.create(
+            stock_item=item,
+            test='sew cushion',
+            result=True
+        )
+    
+        self.assertTrue(item.passedAllRequiredTests())
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 39bc7138b1..29a57f5926 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -254,6 +254,8 @@ class StockItemTestResultCreate(AjaxCreateView):
         except (ValueError, StockItem.DoesNotExist):
             pass
 
+        initials['test'] = self.request.GET.get('test', '')
+
         return initials
 
     def get_form(self):
@@ -261,6 +263,17 @@ class StockItemTestResultCreate(AjaxCreateView):
         form = super().get_form()
         form.fields['stock_item'].widget = HiddenInput()
 
+        # Extract the StockItem object
+        item_id = form['stock_item'].value()
+
+        # Limit the options for the file attachments
+        try:
+            stock_item = StockItem.objects.get(pk=item_id)
+            form.fields['attachment'].queryset = stock_item.attachments.all()
+        except (ValueError, StockItem.DoesNotExist):
+            # Hide the attachments field
+            form.fields['attachment'].widget = HiddenInput()
+
         return form
 
 
@@ -278,6 +291,8 @@ class StockItemTestResultEdit(AjaxUpdateView):
         form = super().get_form()
 
         form.fields['stock_item'].widget = HiddenInput()
+
+        form.fields['attachment'].queryset = self.object.stock_item.attachments.all()
         
         return form
 
diff --git a/InvenTree/templates/js/part.html b/InvenTree/templates/js/part.html
index 57e7fa5798..528fc9aa93 100644
--- a/InvenTree/templates/js/part.html
+++ b/InvenTree/templates/js/part.html
@@ -284,4 +284,85 @@ function loadPartTable(table, url, options={}) {
 
         location.href = '/part/export/?parts=' + parts;
     });
-}
\ No newline at end of file
+}
+
+
+function loadPartTestTemplateTable(table, options) {
+    /*
+     * Load PartTestTemplate table.
+     */
+    
+    var params = options.params || {};
+
+    var part = options.part || null;
+
+    var filterListElement = options.filterList || '#filter-list-parttests';
+
+    var filters = loadTableFilters("parttests");
+
+    var original = {};
+
+    for (var key in params) {
+        original[key] = params[key];
+    }
+
+    setupFilterList("parttests", table, filterListElement);
+
+    // Override the default values, or add new ones
+    for (var key in params) {
+        filters[key] = params[key];
+    }
+
+    table.inventreeTable({
+        method: 'get',
+        formatNoMatches: function() {
+            return '{% trans "No test templates matching query" %}';
+        },
+        url: "{% url 'api-part-test-template-list' %}",
+        queryParams: filters,
+        original: original,
+        columns: [
+            {
+                field: 'pk',
+                title: 'ID',
+                visible: false,
+            },
+            {
+                field: 'test_name',
+                title: "{% trans "Test Name" %}",
+                sortable: true,
+            },
+            {
+                field: 'required',
+                title: "{% trans 'Required' %}",
+                sortable: true,
+                formatter: function(value) {
+                    if (value) {
+                        return `<span class='label label-green'>{% trans "YES" %}</span>`;
+                    } else {
+                        return `<span class='label label-yellow'>{% trans "NO" %}</span>`;
+                    }
+                }
+            },
+            {
+                field: 'buttons',
+                formatter: function(value, row) {
+                    var pk = row.pk;
+
+                    if (row.part == part) {
+                        var html = `<div class='btn-group float-right' role='group'>`;
+                        
+                        html += makeIconButton('fa-edit icon-blue', 'button-test-edit', pk, '{% trans "Edit test result" %}');
+                        html += makeIconButton('fa-trash-alt icon-red', 'button-test-delete', pk, '{% trans "Delete test result" %}');
+
+                        html += `</div>`;
+
+                        return html;
+                    } else {
+                        return '{% trans "This test is defined for a parent part" %}';
+                    }
+                }
+            }
+        ]
+    });
+}
diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html
index af65782bef..49c3151ff0 100644
--- a/InvenTree/templates/js/stock.html
+++ b/InvenTree/templates/js/stock.html
@@ -22,79 +22,102 @@ function removeStockRow(e) {
 function passFailBadge(result) {
 
     if (result) {
-        return `<span class='label label-green'>{% trans "PASS" %}</span>`;
+        return `<span class='label label-green float-right'>{% trans "PASS" %}</span>`;
     } else {
-        return `<span class='label label-red'>{% trans "FAIL" %}</span>`;
+        return `<span class='label label-red float-right'>{% trans "FAIL" %}</span>`;
     }
 }
 
+function noResultBadge() {
+    return `<span class='label label-blue float-right'>{% trans "NO RESULT" %}</span>`;
+}
+
+function testKey(test_name) {
+    // Convert test name to a unique key without any illegal chars
+
+    test_name = test_name.trim().toLowerCase();
+    test_name = test_name.replace(' ', '');
+
+    test_name = test_name.replace(/[^0-9a-z]/gi, '');
+
+    return test_name;
+}
+
 function loadStockTestResultsTable(table, options) {
     /*
      * Load StockItemTestResult table
      */
 
-    var params = options.params || {};
-    
-    // HTML element to setup the filtering
-    var filterListElement = options.filterList || '#filter-list-stocktests';
+    function formatDate(row) {
+        // Function for formatting date field
+        var html = row.date;
 
-    var filters = {};
+        if (row.user_detail) {
+            html += `<span class='badge'>${row.user_detail.username}</span>`;
+        }
 
-    filters = loadTableFilters("stocktests");
+        if (row.attachment_detail) {
+            html += `<a href='${row.attachment_detail.attachment}'><span class='fas fa-file-alt label-right'></span></a>`;
+        }
 
-    var original = {};
-
-    for (var key in params) {
-        original[key] = params[key];
+        return html;
     }
 
-    setupFilterList("stocktests", table, filterListElement);
+    function makeButtons(row, grouped) {
+        var html = `<div class='btn-group float-right' role='group'>`;
 
-    // Override the default values, or add new ones
-    for (var key in params) {
-        filters[key] = params[key];
+        html += makeIconButton('fa-plus icon-green', 'button-test-add', row.test_name, '{% trans "Add test result" %}');
+
+        if (!grouped && row.result != null) {
+            var pk = row.pk;
+            html += makeIconButton('fa-edit icon-blue', 'button-test-edit', pk, '{% trans "Edit test result" %}');
+            html += makeIconButton('fa-trash-alt icon-red', 'button-test-delete', pk, '{% trans "Delete test result" %}');
+        }
+        
+        html += "</div>";
+
+        return html;
     }
 
+    // First, load all the test templates
     table.inventreeTable({
+        url: "{% url 'api-part-test-template-list' %}",
         method: 'get',
         formatNoMatches: function() {
-            return '{% trans "No test results matching query" %}';
+            return "{% trans 'No test results found' %}";
+        },
+        queryParams: {
+            part: options.part,
         },
-        url: "{% url 'api-stock-test-result-list' %}",
-        queryParams: filters,
-        original: original,
         columns: [
             {
                 field: 'pk',
                 title: 'ID',
-                visible: false
+                visible: false,
             },
             {
-                field: 'test',
-                title: '{% trans "Test" %}',
+                field: 'test_name',
+                title: "{% trans "Test Name" %}",
                 sortable: true,
                 formatter: function(value, row) {
                     var html = value;
 
-                    if (row.attachment_detail) {
-                        html += `<a href='${row.attachment_detail.attachment}'><span class='fas fa-file-alt label-right'></span></a>`;
+                    if (row.required) {
+                        html = `<b>${value}</b>`;
+                    }
+
+                    if (row.result == null) {
+                        html += noResultBadge();
+                    } else {
+                        html += passFailBadge(row.result);
                     }
 
                     return html;
-                },
-            },
-            {
-                field: 'result',
-                title: "{% trans "Result" %}",
-                sortable: true,
-                formatter: function(value) {
-                    return passFailBadge(value);
                 }
             },
             {
                 field: 'value',
-                title: "{% trans "Value" %}",
-                sortable: true,
+                title: '{% trans "Value" %}',
             },
             {
                 field: 'notes',
@@ -102,35 +125,98 @@ function loadStockTestResultsTable(table, options) {
             },
             {
                 field: 'date',
-                title: '{% trans "Uploaded" %}',
-                sortable: true,
+                title: '{% trans "Test Date" %}',
                 formatter: function(value, row) {
-                    var html = value;
-
-                    if (row.user_detail) {
-                        html += `<span class='badge'>${row.user_detail.username}</span>`;
-                    }
-
-                    return html;
+                    return formatDate(row);
                 }
             },
             {
                 field: 'buttons',
                 formatter: function(value, row) {
-
-                    var pk = row.pk;
-
-                    var html = `<div class='btn-group float-right' role='group'>`;
-                    
-                    html += makeIconButton('fa-edit icon-blue', 'button-test-edit', pk, '{% trans "Edit test result" %}');
-                    html += makeIconButton('fa-trash-alt icon-red', 'button-test-delete', pk, '{% trans "Delete test result" %}');
-
-                    html += `</div>`;
-
-                    return html;
+                    return makeButtons(row, false);
                 }
             },
-        ]
+        ],
+        groupBy: true,
+        groupByField: 'test_name',
+        groupByFormatter: function(field, id, data) {
+
+            // Extract the "latest" row (data are returned in date order from the server)
+            var latest = data[data.length-1];
+
+            switch (field) {
+            case 'test_name':
+                return latest.test_name + ` <i>(${data.length})</i>` + passFailBadge(latest.result);
+            case 'value':
+                return latest.value;
+            case 'notes':
+                return latest.notes;
+            case 'date':
+                return formatDate(latest);
+            case 'buttons':
+                // Buttons are done differently for grouped rows
+                return makeButtons(latest, true);
+            default:
+                return "---";
+            }
+        },
+        onLoadSuccess: function(tableData) {
+            // Once the test template data are loaded, query for results
+            inventreeGet(
+                "{% url 'api-stock-test-result-list' %}",
+                {
+                    stock_item: options.stock_item,
+                    user_detail: true,
+                    attachment_detail: true,
+                },
+                {
+                    success: function(data) {
+
+                        // Iterate through the returned test result data, and group by test
+                        data.forEach(function(item) {
+                            var match = false;
+                            var override = false;
+
+                            var key = testKey(item.test);
+
+                            // Try to associate this result with a test row
+                            tableData.forEach(function(row, index) {
+                                
+                                
+                                // The result matches the test template row
+                                if (key == testKey(row.test_name)) {
+                                    
+                                    // Force the names to be the same!
+                                    item.test_name = row.test_name;
+                                    item.required = row.required;
+
+                                    if (row.result == null) {
+                                        // The original row has not recorded a result - override!
+                                        tableData[index] = item;
+                                        override = true;
+                                    }
+
+                                    match = true;
+                                }
+                            });
+
+                            // No match could be found (this is a new test!)
+                            if (!match) {
+
+                                item.test_name = item.test;
+                            }
+
+                            if (!override) {
+                                tableData.push(item);
+                            }
+                        });
+
+                        // Finally, push the data back into the table!
+                        table.bootstrapTable("load", tableData);
+                    }
+                },
+            );
+        }
     });
 }
 
diff --git a/InvenTree/templates/js/table_filters.html b/InvenTree/templates/js/table_filters.html
index 955703a7c9..84b517e8e7 100644
--- a/InvenTree/templates/js/table_filters.html
+++ b/InvenTree/templates/js/table_filters.html
@@ -44,7 +44,17 @@ function getAvailableTableFilters(tableKey) {
                 type: 'bool',
                 title: "{% trans 'Test result' %}",
             },
-        }
+        };
+    }
+
+    // Filters for the 'part test template' table
+    if (tableKey == 'parttests') {
+        return {
+            required: {
+                type: 'bool',
+                title: "{% trans "Required" %}",
+            }
+        };
     }
 
     // Filters for the "Build" table