diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/EntityPatch.java b/src/main/java/net/hostsharing/hsadminng/EntityPatch.java
similarity index 55%
rename from src/main/java/net/hostsharing/hsadminng/hs/office/partner/EntityPatch.java
rename to src/main/java/net/hostsharing/hsadminng/EntityPatch.java
index b9ec6ac5..a21c931d 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/EntityPatch.java
+++ b/src/main/java/net/hostsharing/hsadminng/EntityPatch.java
@@ -1,4 +1,4 @@
-package net.hostsharing.hsadminng.hs.office.partner;
+package net.hostsharing.hsadminng;
 
 public interface EntityPatch<R> {
 
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatch.java b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatch.java
index 96aa5137..9893807b 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatch.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatch.java
@@ -1,5 +1,6 @@
 package net.hostsharing.hsadminng.hs.office.partner;
 
+import net.hostsharing.hsadminng.EntityPatch;
 import net.hostsharing.hsadminng.OptionalFromJson;
 import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
 import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerPatchResource;
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatch.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatch.java
index e06e2a1d..659011a2 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatch.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatch.java
@@ -3,10 +3,11 @@ package net.hostsharing.hsadminng.hs.office.person;
 import net.hostsharing.hsadminng.OptionalFromJson;
 import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonPatchResource;
 import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonTypeResource;
+import net.hostsharing.hsadminng.EntityPatch;
 
 import java.util.Optional;
 
-class HsOfficePersonEntityPatch {
+class HsOfficePersonEntityPatch implements EntityPatch<HsOfficePersonPatchResource> {
 
     private final HsOfficePersonEntity entity;
 
@@ -14,7 +15,8 @@ class HsOfficePersonEntityPatch {
         this.entity = entity;
     }
 
-    void apply(final HsOfficePersonPatchResource resource) {
+    @Override
+    public void apply(final HsOfficePersonPatchResource resource) {
         Optional.ofNullable(resource.getPersonType())
                 .map(HsOfficePersonTypeResource::getValue)
                 .map(HsOfficePersonType::valueOf)
diff --git a/src/test/java/net/hostsharing/hsadminng/PatchUnitTestBase.java b/src/test/java/net/hostsharing/hsadminng/PatchUnitTestBase.java
new file mode 100644
index 00000000..6eaea7d2
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/PatchUnitTestBase.java
@@ -0,0 +1,312 @@
+package net.hostsharing.hsadminng;
+
+import org.junit.jupiter.api.Named;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.openapitools.jackson.nullable.JsonNullable;
+
+import java.util.NoSuchElementException;
+import java.util.UUID;
+import java.util.function.BiConsumer;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowableOfType;
+import static org.assertj.core.api.Assumptions.assumeThat;
+
+public abstract class PatchUnitTestBase<R, E> {
+
+    @Test
+    void willPatchNoProperty() {
+        // given
+        final var givenEntity = newInitialEntity();
+        final var patchResource = newPatchResource();
+
+        // when
+        createPatcher(givenEntity).apply(patchResource);
+
+        // then
+        final var expectedEntity = newInitialEntity();
+        assertThat(givenEntity).usingRecursiveComparison().isEqualTo(expectedEntity);
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    void willPatchAllProperties() {
+        // given
+        final var givenEntity = newInitialEntity();
+        final var patchResource = newPatchResource();
+        propertyTestDescriptors().forEach(testCase ->
+                testCase.patchResource(patchResource)
+        );
+
+        // when
+        createPatcher(givenEntity).apply(patchResource);
+
+        // then
+        final var expectedEntity = newInitialEntity();
+        propertyTestDescriptors().forEach(testCase ->
+                testCase.updateEntity(expectedEntity)
+        );
+        assertThat(givenEntity).usingRecursiveComparison().isEqualTo(expectedEntity);
+    }
+
+    @ParameterizedTest
+    @MethodSource("propertyTestCases")
+    void willPatchOnlyGivenProperty(final Property<R, Object, E, Object> testCase) {
+
+        // given
+        final var givenEntity = newInitialEntity();
+        final var patchResource = newPatchResource();
+        testCase.patchResource(patchResource);
+
+        // when
+        createPatcher(givenEntity).apply(patchResource);
+
+        // then
+        final var expectedEntity = newInitialEntity();
+        testCase.updateEntity(expectedEntity);
+        assertThat(givenEntity).usingRecursiveComparison().isEqualTo(expectedEntity);
+    }
+
+    @ParameterizedTest
+    @MethodSource("propertyTestCases")
+    void willThrowIfUUidCannotBeResolved(final Property<R, Object, E, Object> testCase) {
+        assumeThat(testCase.resolvesUuid).isTrue();
+
+        // given
+        final var givenEntity = newInitialEntity();
+        final var patchResource = newPatchResource();
+        final var givenPatchValue = UUID.fromString("11111111-1111-1111-1111-111111111111");
+        testCase.patchResourceWithExplicitValue(patchResource, givenPatchValue);
+
+        // when
+        final var exception = catchThrowableOfType(() -> {
+            createPatcher(givenEntity).apply(patchResource);
+        }, NoSuchElementException.class);
+
+        // then
+        assertThat(exception).isInstanceOf(NoSuchElementException.class)
+                .hasMessage("cannot find '" + testCase.name + "' uuid " + givenPatchValue);
+    }
+
+    @ParameterizedTest
+    @MethodSource("propertyTestCases")
+    void willThrowExceptionIfNotNullableUuidIsNull(final Property<R, Object, E, Object> testCase) {
+        assumeThat(testCase.resolvesUuid).isTrue();
+        assumeThat(testCase.nullable).isFalse();
+
+        // given
+        final var givenEntity = newInitialEntity();
+        final var patchResource = newPatchResource();
+        testCase.patchResourceToNullValue(patchResource);
+
+        // when
+        final var actualException = catchThrowableOfType(
+                () -> createPatcher(givenEntity).apply(patchResource),
+                IllegalArgumentException.class);
+
+        // then
+        assertThat(actualException).hasMessage("property '" + testCase.name + "' must not be null");
+        assertThat(givenEntity).usingRecursiveComparison().isEqualTo(newInitialEntity());
+    }
+
+    @ParameterizedTest
+    @MethodSource("propertyTestCases")
+    void willPatchOnlyGivenPropertyToNull(final Property<R, Object, E, Object> testCase) {
+        assumeThat(testCase.nullable).isTrue();
+
+        // given
+        final var givenEntity = newInitialEntity();
+        final var patchResource = newPatchResource();
+        testCase.patchResourceToNullValue(patchResource);
+
+        // when
+        createPatcher(givenEntity).apply(patchResource);
+
+        // then
+        final var expectedEntity = newInitialEntity();
+        testCase.entitySetter.accept(expectedEntity, null);
+        assertThat(givenEntity).usingRecursiveComparison().isEqualTo(expectedEntity);
+    }
+
+    @ParameterizedTest
+    @MethodSource("propertyTestCases")
+    void willNotPatchIfGivenPropertyNotGiven(final Property<R, Object, E, Object> testCase) {
+
+        // given
+        final var givenEntity = newInitialEntity();
+        final var patchResource = newPatchResource();
+        testCase.patchResourceToNotGiven(patchResource);
+
+        // when
+        createPatcher(givenEntity).apply(patchResource);
+
+        // then
+        final var expectedEntity = newInitialEntity();
+        assertThat(givenEntity).usingRecursiveComparison().isEqualTo(expectedEntity);
+    }
+
+    protected abstract E newInitialEntity();
+
+    protected abstract R newPatchResource();
+
+    protected abstract EntityPatch<R> createPatcher(final E entity);
+
+    @SuppressWarnings("rawtypes")
+    protected abstract Stream<Property> propertyTestDescriptors();
+
+    private Stream<Arguments> propertyTestCases() {
+        return propertyTestDescriptors()
+                .map(tc -> Arguments.of(Named.of(tc.name, tc)));
+    }
+
+    protected static abstract class Property<R, RV, E, EV> {
+
+        protected final String name;
+        protected final BiConsumer<E, EV> entitySetter;
+        protected final EV expectedPatchValue;
+        protected boolean nullable = true;
+        private boolean resolvesUuid = false;
+
+        protected Property(
+                final String name,
+                final BiConsumer<E, EV> entitySetter,
+                final EV givenPatchValue) {
+            this.name = name;
+
+            this.entitySetter = entitySetter;
+            this.expectedPatchValue = givenPatchValue;
+        }
+
+        protected abstract void patchResource(R patchResource);
+
+        protected abstract void patchResourceToNullValue(R patchResource);
+
+        protected abstract void patchResourceToNotGiven(R patchResource);
+
+        protected abstract void patchResourceWithExplicitValue(final R patchResource, final RV explicitPatchValue);
+
+        void updateEntity(final E expectedEntity) {
+            entitySetter.accept(expectedEntity, expectedPatchValue);
+        }
+
+        public Property<R, RV, E, EV> notNullable() {
+            nullable = false;
+            return this;
+        }
+
+        public Property<R, RV, E, EV> resolvesUuid() {
+            resolvesUuid = true;
+            return this;
+        }
+
+        @SuppressWarnings("unchecked")
+        protected static <EV, RV> EV sameAs(final RV givenResourceValue) {
+            return (EV) givenResourceValue;
+        }
+    }
+
+    protected static class SimpleProperty<R, RV, E, EV> extends Property<R, RV, E, EV> {
+
+        public final RV givenPatchValue;
+        private final BiConsumer<R, RV> resourceSetter;
+
+        public SimpleProperty(
+                final String name,
+                final BiConsumer<R, RV> resourceSetter,
+                final RV givenPatchValue,
+                final BiConsumer<E, EV> entitySetter
+        ) {
+            super(name, entitySetter, sameAs(givenPatchValue));
+            this.resourceSetter = resourceSetter;
+            this.givenPatchValue = givenPatchValue;
+        }
+
+        public SimpleProperty(
+                final String name,
+                final BiConsumer<R, RV> resourceSetter,
+                final RV givenPatchValue,
+                final BiConsumer<E, EV> entitySetter,
+                final EV expectedPatchValue
+        ) {
+            super(name, entitySetter, expectedPatchValue);
+            this.resourceSetter = resourceSetter;
+            this.givenPatchValue = givenPatchValue;
+        }
+
+        @Override
+        protected void patchResource(final R patchResource) {
+            resourceSetter.accept(patchResource, givenPatchValue);
+        }
+
+        @Override
+        protected void patchResourceToNullValue(final R patchResource) {
+            assertThat(nullable).isTrue(); // null can mean "not given" or "null value", not both
+            resourceSetter.accept(patchResource, null);
+        }
+
+        @Override
+        protected void patchResourceToNotGiven(final R patchResource) {
+            assertThat(nullable).isFalse(); // null can mean "not given" or "null value", not both
+            resourceSetter.accept(patchResource, null);
+        }
+
+        @Override
+        protected void patchResourceWithExplicitValue(final R patchResource, final RV explicitPatchValue) {
+            resourceSetter.accept(patchResource, explicitPatchValue);
+        }
+    }
+
+    protected static class JsonNullableProperty<R, RV, E, EV> extends Property<R, RV, E, EV> {
+
+        private final BiConsumer<R, JsonNullable<RV>> resourceSetter;
+        public final RV givenPatchValue;
+
+        public JsonNullableProperty(
+                final String name,
+                final BiConsumer<R, JsonNullable<RV>> resourceSetter,
+                final RV givenPatchValue,
+                final BiConsumer<E, EV> entitySetter
+        ) {
+            super(name, entitySetter, sameAs(givenPatchValue));
+            this.resourceSetter = resourceSetter;
+            this.givenPatchValue = givenPatchValue;
+        }
+
+        public JsonNullableProperty(
+                final String name,
+                final BiConsumer<R, JsonNullable<RV>> resourceSetter,
+                final RV givenPatchValue,
+                final BiConsumer<E, EV> entitySetter,
+                final EV expectedPatchValue
+        ) {
+            super(name, entitySetter, expectedPatchValue);
+            this.resourceSetter = resourceSetter;
+            this.givenPatchValue = givenPatchValue;
+        }
+
+        @Override
+        protected void patchResource(final R patchResource) {
+            resourceSetter.accept(patchResource, JsonNullable.of(givenPatchValue));
+        }
+
+        @Override
+        protected void patchResourceToNullValue(final R patchResource) {
+            resourceSetter.accept(patchResource, JsonNullable.of(null));
+        }
+
+        @Override
+        protected void patchResourceToNotGiven(final R patchResource) {
+            resourceSetter.accept(patchResource, null);
+        }
+
+        @Override
+        protected void patchResourceWithExplicitValue(final R patchResource, final RV explicitPatchValue) {
+            resourceSetter.accept(patchResource, JsonNullable.of(explicitPatchValue));
+
+        }
+    }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatchUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatchUnitTest.java
index 9bba6ea1..a1460c76 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatchUnitTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatchUnitTest.java
@@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.office.partner;
 import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
 import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerPatchResource;
 import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
+import net.hostsharing.hsadminng.PatchUnitTestBase;
 import org.junit.jupiter.api.TestInstance;
 
 import java.time.LocalDate;
@@ -38,26 +39,26 @@ class HsOfficePartnerEntityPatchUnitTest extends PatchUnitTestBase<
             .build();
 
     @Override
-    HsOfficePartnerEntity newInitialEntity() {
-        final var p = new HsOfficePartnerEntity();
-        p.setUuid(INITIAL_PARTNER_UUID);
-        p.setPerson(givenInitialPerson);
-        p.setContact(givenInitialContact);
-        p.setRegistrationOffice("initial Reg-Office");
-        p.setRegistrationNumber("initial Reg-Number");
-        p.setBirthday(INITIAL_BIRTHDAY);
-        p.setBirthName("initial birth name");
-        p.setDateOfDeath(INITIAL_DAY_OF_DEATH);
-        return p;
+    protected HsOfficePartnerEntity newInitialEntity() {
+        final var entity = new HsOfficePartnerEntity();
+        entity.setUuid(INITIAL_PARTNER_UUID);
+        entity.setPerson(givenInitialPerson);
+        entity.setContact(givenInitialContact);
+        entity.setRegistrationOffice("initial Reg-Office");
+        entity.setRegistrationNumber("initial Reg-Number");
+        entity.setBirthday(INITIAL_BIRTHDAY);
+        entity.setBirthName("initial birth name");
+        entity.setDateOfDeath(INITIAL_DAY_OF_DEATH);
+        return entity;
     }
 
     @Override
-    HsOfficePartnerPatchResource newPatchResource() {
+    protected HsOfficePartnerPatchResource newPatchResource() {
         return new HsOfficePartnerPatchResource();
     }
 
     @Override
-    HsOfficePartnerEntityPatch createPatcher(final HsOfficePartnerEntity partner) {
+    protected HsOfficePartnerEntityPatch createPatcher(final HsOfficePartnerEntity partner) {
         return new HsOfficePartnerEntityPatch(
                 partner,
                 uuid -> uuid == PATCHED_CONTACT_UUID
@@ -69,9 +70,9 @@ class HsOfficePartnerEntityPatchUnitTest extends PatchUnitTestBase<
     }
 
     @Override
-    Stream<TestCase> testCases() {
+    protected Stream<Property> propertyTestDescriptors() {
         return Stream.of(
-                new TestCase(
+                new JsonNullableProperty<>(
                         "contact",
                         HsOfficePartnerPatchResource::setContactUuid,
                         PATCHED_CONTACT_UUID,
@@ -79,7 +80,7 @@ class HsOfficePartnerEntityPatchUnitTest extends PatchUnitTestBase<
                         newContact(PATCHED_CONTACT_UUID))
                         .notNullable()
                         .resolvesUuid(),
-                new TestCase(
+                new JsonNullableProperty<>(
                         "person",
                         HsOfficePartnerPatchResource::setPersonUuid,
                         PATCHED_PERSON_UUID,
@@ -87,17 +88,17 @@ class HsOfficePartnerEntityPatchUnitTest extends PatchUnitTestBase<
                         newPerson(PATCHED_PERSON_UUID))
                         .notNullable()
                         .resolvesUuid(),
-                new TestCase(
+                new JsonNullableProperty<>(
                         "registrationOffice",
                         HsOfficePartnerPatchResource::setRegistrationOffice,
                         "patched Reg-Office",
                         HsOfficePartnerEntity::setRegistrationOffice),
-                new TestCase(
+                new JsonNullableProperty<>(
                         "birthday",
                         HsOfficePartnerPatchResource::setBirthday,
                         PATCHED_BIRTHDAY,
                         HsOfficePartnerEntity::setBirthday),
-                new TestCase(
+                new JsonNullableProperty<>(
                         "dayOfDeath",
                         HsOfficePartnerPatchResource::setDateOfDeath,
                         PATCHED_DATE_OF_DEATH,
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/PatchUnitTestBase.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/PatchUnitTestBase.java
deleted file mode 100644
index 2ec6d0f9..00000000
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/PatchUnitTestBase.java
+++ /dev/null
@@ -1,210 +0,0 @@
-package net.hostsharing.hsadminng.hs.office.partner;
-
-import org.junit.jupiter.api.Named;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.Arguments;
-import org.junit.jupiter.params.provider.MethodSource;
-import org.openapitools.jackson.nullable.JsonNullable;
-
-import java.util.NoSuchElementException;
-import java.util.UUID;
-import java.util.function.BiConsumer;
-import java.util.stream.Stream;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.catchThrowableOfType;
-import static org.assertj.core.api.Assumptions.assumeThat;
-
-public abstract class PatchUnitTestBase<R, E> {
-
-    @Test
-    void willPatchNoProperty() {
-        // given
-        final var givenEntity = newInitialEntity();
-        final var patchResource = newPatchResource();
-
-        // when
-        createPatcher(givenEntity).apply(patchResource);
-
-        // then
-        final var expectedEntity = newInitialEntity();
-        assertThat(givenEntity).usingRecursiveComparison().isEqualTo(expectedEntity);
-    }
-
-    @Test
-    void willPatchAllProperties() {
-        // given
-        final var givenEntity = newInitialEntity();
-        final var patchResource = newPatchResource();
-        testCases().forEach(testCase ->
-                testCase.resourceSetter.accept(patchResource, JsonNullable.of(testCase.givenPatchedValue))
-        );
-
-        // when
-        createPatcher(givenEntity).apply(patchResource);
-
-        // then
-        final var expectedEntity = newInitialEntity();
-        testCases().forEach(testCase ->
-                testCase.entitySetter.accept(expectedEntity, testCase.expectedPatchValue)
-        );
-        assertThat(givenEntity).usingRecursiveComparison().isEqualTo(expectedEntity);
-    }
-
-    @ParameterizedTest
-    @MethodSource("testCaseArguments")
-    void willPatchOnlyGivenProperty(final TestCase testCase) {
-
-        // given
-        final var givenEntity = newInitialEntity();
-        final var patchResource = newPatchResource();
-        testCase.resourceSetter.accept(patchResource, JsonNullable.of(testCase.givenPatchedValue));
-
-        // when
-        createPatcher(givenEntity).apply(patchResource);
-
-        // then
-        final var expectedEntity = newInitialEntity();
-        testCase.entitySetter.accept(expectedEntity, testCase.expectedPatchValue);
-        assertThat(givenEntity).usingRecursiveComparison().isEqualTo(expectedEntity);
-    }
-
-    @ParameterizedTest
-    @MethodSource("testCaseArguments")
-    void willThrowIfUUidCannotBeResolved(final TestCase testCase) {
-        assumeThat(testCase.resolvesUuid).isTrue();
-
-        // given
-        final var givenEntity = newInitialEntity();
-        final var patchResource = newPatchResource();
-        final var givenPatchValue = UUID.fromString("11111111-1111-1111-1111-111111111111");
-        testCase.resourceSetter.accept(patchResource, JsonNullable.of(givenPatchValue));
-
-        // when
-        final var exception = catchThrowableOfType(() -> {
-            createPatcher(givenEntity).apply(patchResource);
-        }, NoSuchElementException.class);
-
-        // then
-        assertThat(exception).isInstanceOf(NoSuchElementException.class)
-                .hasMessage("cannot find '" + testCase.name + "' uuid " + givenPatchValue);
-    }
-
-    @ParameterizedTest
-    @MethodSource("testCaseArguments")
-    void willPatchOnlyGivenPropertyToNull(final TestCase testCase) {
-        assumeThat(testCase.nullable).isTrue();
-
-        // given
-        final var givenEntity = newInitialEntity();
-        final var patchResource = newPatchResource();
-        testCase.resourceSetter.accept(patchResource, JsonNullable.of(null));
-
-        // when
-        createPatcher(givenEntity).apply(patchResource);
-
-        // then
-        final var expectedEntity = newInitialEntity();
-        testCase.entitySetter.accept(expectedEntity, null);
-        assertThat(givenEntity).usingRecursiveComparison().isEqualTo(expectedEntity);
-    }
-
-    @ParameterizedTest
-    @MethodSource("testCaseArguments")
-    void willThrowExceptionIfResourcePropertyIsNull(final TestCase testCase) {
-        assumeThat(testCase.nullable).isFalse();
-
-        // given
-        final var givenEntity = newInitialEntity();
-        final var patchResource = newPatchResource();
-        testCase.resourceSetter.accept(patchResource, JsonNullable.of(null));
-
-        // when
-        final var actualException = catchThrowableOfType(
-                () -> createPatcher(givenEntity).apply(patchResource),
-                IllegalArgumentException.class);
-
-        // then
-        assertThat(actualException).hasMessage("property '" + testCase.name + "' must not be null");
-        assertThat(givenEntity).usingRecursiveComparison().isEqualTo(newInitialEntity());
-    }
-
-    @ParameterizedTest
-    @MethodSource("testCaseArguments")
-    void willNotPatchIfGivenPropertyNotGiven(final TestCase testCase) {
-
-        // given
-        final var givenEntity = newInitialEntity();
-        final var patchResource = newPatchResource();
-        testCase.resourceSetter.accept(patchResource, null);
-
-        // when
-        createPatcher(givenEntity).apply(patchResource);
-
-        // then
-        final var expectedEntity = newInitialEntity();
-        assertThat(givenEntity).usingRecursiveComparison().isEqualTo(expectedEntity);
-    }
-
-    abstract E newInitialEntity();
-
-    abstract R newPatchResource();
-
-    abstract EntityPatch<R> createPatcher(final E entity);
-
-    abstract Stream<TestCase> testCases();
-
-    Stream<Arguments> testCaseArguments() {
-        return testCases().map(tc -> Arguments.of(Named.of(tc.name, tc)));
-    }
-
-    class TestCase {
-
-        private final String name;
-        public final Object givenPatchedValue;
-        private final BiConsumer<Object, JsonNullable<?>> resourceSetter;
-        private final BiConsumer<Object, Object> entitySetter;
-        private final Object expectedPatchValue;
-
-        private boolean nullable = true;
-        private boolean resolvesUuid = false;
-
-        <R, V, E> TestCase(
-                final String name,
-                final BiConsumer<R, JsonNullable<V>> resourceSetter,
-                final V givenPatchValue,
-                final BiConsumer<E, V> entitySetter
-        ) {
-            this.name = name;
-            this.resourceSetter = (BiConsumer<Object, JsonNullable<?>>) (BiConsumer) resourceSetter;
-            this.givenPatchedValue = givenPatchValue;
-            this.entitySetter = (BiConsumer<Object, Object>) entitySetter;
-            this.expectedPatchValue = givenPatchValue;
-        }
-
-        <R, V, E, S> TestCase(
-                final String name,
-                final BiConsumer<R, JsonNullable<V>> resourceSetter,
-                final V givenPatchValue,
-                final BiConsumer<E, S> entitySetter,
-                final S expectedPatchValue
-        ) {
-            this.name = name;
-            this.resourceSetter = (BiConsumer<Object, JsonNullable<?>>) (BiConsumer) resourceSetter;
-            this.givenPatchedValue = givenPatchValue;
-            this.entitySetter = (BiConsumer<Object, Object>) entitySetter;
-            this.expectedPatchValue = expectedPatchValue;
-        }
-
-        TestCase notNullable() {
-            nullable = false;
-            return this;
-        }
-
-        TestCase resolvesUuid() {
-            resolvesUuid = true;
-            return this;
-        }
-    }
-}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatchUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatchUnitTest.java
index d0f93f50..bf3d9c83 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatchUnitTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatchUnitTest.java
@@ -1,153 +1,70 @@
 package net.hostsharing.hsadminng.hs.office.person;
 
+import net.hostsharing.hsadminng.PatchUnitTestBase;
 import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonPatchResource;
 import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonTypeResource;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.EnumSource;
-import org.junit.jupiter.params.provider.NullSource;
-import org.junit.jupiter.params.provider.ValueSource;
-import org.openapitools.jackson.nullable.JsonNullable;
+import org.junit.jupiter.api.TestInstance;
 
 import java.util.UUID;
+import java.util.stream.Stream;
 
-import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
 
-// TODO: there must be an easier way to test such patch classes
-class HsOfficePersonEntityPatchUnitTest {
+@TestInstance(PER_CLASS)
+class HsOfficePersonEntityPatchUnitTest extends PatchUnitTestBase<
+        HsOfficePersonPatchResource,
+        HsOfficePersonEntity
+        > {
 
     private static final UUID INITIAL_PERSON_UUID = UUID.randomUUID();
-    final HsOfficePersonEntity givenPerson = new HsOfficePersonEntity();
-    final HsOfficePersonPatchResource patchResource = new HsOfficePersonPatchResource();
 
-    private final HsOfficePersonEntityPatch hsOfficePersonEntityPatch =
-            new HsOfficePersonEntityPatch(givenPerson);
-
-    {
-        givenPerson.setUuid(INITIAL_PERSON_UUID);
-        givenPerson.setPersonType(HsOfficePersonType.LEGAL);
-        givenPerson.setTradeName("initial@example.org");
-        givenPerson.setFamilyName("initial postal address");
-        givenPerson.setGivenName("+01 100 123456789");
+    @Override
+    protected HsOfficePersonEntity newInitialEntity() {
+        final var entity = new HsOfficePersonEntity();
+        entity.setUuid(INITIAL_PERSON_UUID);
+        entity.setPersonType(HsOfficePersonType.LEGAL);
+        entity.setTradeName("initial@example.org");
+        entity.setFamilyName("initial postal address");
+        entity.setGivenName("+01 100 123456789");
+        return entity;
     }
 
-    @Test
-    void willPatchAllProperties() {
-        // given
-        patchResource.setPersonType(HsOfficePersonTypeResource.NATURAL);
-        patchResource.setTradeName(JsonNullable.of("patched@example.org"));
-        patchResource.setFamilyName(JsonNullable.of("patched postal address"));
-        patchResource.setGivenName(JsonNullable.of("+01 200 987654321"));
-
-        // when
-        hsOfficePersonEntityPatch.apply(patchResource);
-
-        // then
-        new HsOfficePersonEntityMatcher()
-                .withPatchedPersonType(HsOfficePersonType.NATURAL)
-                .withPatchedTradeName("patched@example.org")
-                .withPatchedFamilyName("patched postal address")
-                .withPatchedGivenName("+01 200 987654321")
-                .matches(givenPerson);
+    @Override
+    protected HsOfficePersonPatchResource newPatchResource() {
+        return new HsOfficePersonPatchResource();
     }
 
-    @ParameterizedTest
-    @EnumSource(HsOfficePersonTypeResource.class)
-    void willPatchOnlyPersonTypeProperty(final HsOfficePersonTypeResource patchedValue) {
-        // given
-        patchResource.setPersonType(patchedValue);
-
-        // when
-        hsOfficePersonEntityPatch.apply(patchResource);
-
-        // then
-        new HsOfficePersonEntityMatcher()
-                .withPatchedPersonType(HsOfficePersonType.valueOf(patchedValue.getValue()))
-                .matches(givenPerson);
+    @Override
+    protected HsOfficePersonEntityPatch createPatcher(final HsOfficePersonEntity entity) {
+        return new HsOfficePersonEntityPatch(entity);
     }
 
-    @ParameterizedTest
-    @ValueSource(strings = { "patched@example.org" })
-    @NullSource
-    void willPatchOnlyTradeNameProperty(final String patchedValue) {
-        // given
-        patchResource.setTradeName(JsonNullable.of(patchedValue));
-
-        // when
-        hsOfficePersonEntityPatch.apply(patchResource);
-
-        // then
-        new HsOfficePersonEntityMatcher()
-                .withPatchedTradeName(patchedValue)
-                .matches(givenPerson);
+    @Override
+    protected Stream<Property> propertyTestDescriptors() {
+        return Stream.of(
+                new SimpleProperty<>(
+                        "personType",
+                        HsOfficePersonPatchResource::setPersonType,
+                        HsOfficePersonTypeResource.SOLE_REPRESENTATION,
+                        HsOfficePersonEntity::setPersonType,
+                        HsOfficePersonType.SOLE_REPRESENTATION)
+                        .notNullable(),
+                new JsonNullableProperty<>(
+                        "tradeName",
+                        HsOfficePersonPatchResource::setTradeName,
+                        "patched trade name",
+                        HsOfficePersonEntity::setTradeName),
+                new JsonNullableProperty<>(
+                        "familyName",
+                        HsOfficePersonPatchResource::setFamilyName,
+                        "patched family name",
+                        HsOfficePersonEntity::setFamilyName),
+                new JsonNullableProperty<>(
+                        "patched given name",
+                        HsOfficePersonPatchResource::setGivenName,
+                        "patched given name",
+                        HsOfficePersonEntity::setGivenName)
+        );
     }
 
-    @ParameterizedTest
-    @ValueSource(strings = { "patched postal address" })
-    @NullSource
-    void willPatchOnlyFamilyNameProperty(final String patchedValue) {
-        // given
-        patchResource.setFamilyName(JsonNullable.of(patchedValue));
-
-        // when
-        hsOfficePersonEntityPatch.apply(patchResource);
-
-        // then
-        new HsOfficePersonEntityMatcher()
-                .withPatchedFamilyName(patchedValue)
-                .matches(givenPerson);
-    }
-
-    @ParameterizedTest
-    @ValueSource(strings = { "+01 200 987654321" })
-    @NullSource
-    void willPatchOnlyGivenNameProperty(final String patchedValue) {
-        // given
-        patchResource.setGivenName(JsonNullable.of(patchedValue));
-
-        // when
-        hsOfficePersonEntityPatch.apply(patchResource);
-
-        // then
-        new HsOfficePersonEntityMatcher()
-                .withPatchedGivenName(patchedValue)
-                .matches(givenPerson);
-    }
-
-    private static class HsOfficePersonEntityMatcher {
-
-        private HsOfficePersonType expectedPersonType = HsOfficePersonType.LEGAL;
-        private String expectedTradeName = "initial@example.org";
-        private String expectedFamilyName = "initial postal address";
-
-        private String expectedGivenName = "+01 100 123456789";
-
-        HsOfficePersonEntityMatcher withPatchedPersonType(final HsOfficePersonType patchedPersonType) {
-            expectedPersonType = patchedPersonType;
-            return this;
-        }
-
-        HsOfficePersonEntityMatcher withPatchedTradeName(final String patchedTradeName) {
-            expectedTradeName = patchedTradeName;
-            return this;
-        }
-
-        HsOfficePersonEntityMatcher withPatchedFamilyName(final String patchedFamilyName) {
-            expectedFamilyName = patchedFamilyName;
-            return this;
-        }
-
-        HsOfficePersonEntityMatcher withPatchedGivenName(final String patchedGivenName) {
-            expectedGivenName = patchedGivenName;
-            return this;
-        }
-
-        void matches(final HsOfficePersonEntity givenPerson) {
-
-            assertThat(givenPerson.getPersonType()).isEqualTo(expectedPersonType);
-            assertThat(givenPerson.getTradeName()).isEqualTo(expectedTradeName);
-            assertThat(givenPerson.getFamilyName()).isEqualTo(expectedFamilyName);
-            assertThat(givenPerson.getGivenName()).isEqualTo(expectedGivenName);
-        }
-    }
 }