diff --git a/commitlint.yaml b/commitlint.yaml
index ee82dc00..f91b8ff0 100644
--- a/commitlint.yaml
+++ b/commitlint.yaml
@@ -25,4 +25,5 @@ rules:
       - neon_notifications
       - neon_lints
       - nextcloud
+      - nextcloud_test
       - sort_box
diff --git a/cspell.json b/cspell.json
index 7ccaa88e..1ee6fa6c 100644
--- a/cspell.json
+++ b/cspell.json
@@ -14,7 +14,7 @@
     "packages/dynamite/dynamite_petstore_example/lib",
     "packages/file_icons/lib/src/data.dart",
     "packages/neon_lints/lib",
-    "tool/dev/static"
+    "packages/nextcloud_test/docker/static"
   ],
   "dictionaries": [
     "bash",
diff --git a/packages/nextcloud/pubspec.yaml b/packages/nextcloud/pubspec.yaml
index e196e101..95ff44d4 100644
--- a/packages/nextcloud/pubspec.yaml
+++ b/packages/nextcloud/pubspec.yaml
@@ -37,6 +37,10 @@ dev_dependencies:
     git:
       url: https://github.com/nextcloud/neon
       path: packages/neon_lints
+  nextcloud_test:
+    git:
+      url: https://github.com/nextcloud/neon
+      path: packages/nextcloud_test
   path: ^1.8.3
   process_run: ^0.13.3
   test: ^1.24.9
diff --git a/packages/nextcloud/pubspec_overrides.yaml b/packages/nextcloud/pubspec_overrides.yaml
index b3ed199c..ac5350aa 100644
--- a/packages/nextcloud/pubspec_overrides.yaml
+++ b/packages/nextcloud/pubspec_overrides.yaml
@@ -1,4 +1,4 @@
-# melos_managed_dependency_overrides: dynamite,dynamite_runtime,neon_lints
+# melos_managed_dependency_overrides: dynamite,dynamite_runtime,neon_lints,nextcloud_test
 dependency_overrides:
   dynamite:
     path: ../dynamite/dynamite
@@ -6,3 +6,5 @@ dependency_overrides:
     path: ../dynamite/dynamite_runtime
   neon_lints:
     path: ../neon_lints
+  nextcloud_test:
+    path: ../nextcloud_test
diff --git a/packages/nextcloud/test/core_test.dart b/packages/nextcloud/test/core_test.dart
index 86c17154..56ab9196 100644
--- a/packages/nextcloud/test/core_test.dart
+++ b/packages/nextcloud/test/core_test.dart
@@ -1,18 +1,17 @@
 import 'package:nextcloud/core.dart' as core;
 import 'package:nextcloud/nextcloud.dart';
+import 'package:nextcloud_test/nextcloud_test.dart';
 import 'package:test/test.dart';
 
-import 'helper.dart';
-
 void main() {
   group(
     'core',
     () {
       late DockerContainer container;
-      late TestNextcloudClient client;
+      late NextcloudClient client;
       setUp(() async {
-        container = await getDockerContainer();
-        client = await getTestClient(container);
+        container = await DockerContainer.create();
+        client = await TestNextcloudClient.create(container);
       });
       tearDown(() => container.destroy());
 
diff --git a/packages/nextcloud/test/dashboard_test.dart b/packages/nextcloud/test/dashboard_test.dart
index 97ba4f1a..fec46ecc 100644
--- a/packages/nextcloud/test/dashboard_test.dart
+++ b/packages/nextcloud/test/dashboard_test.dart
@@ -1,17 +1,17 @@
 import 'package:nextcloud/dashboard.dart';
+import 'package:nextcloud/nextcloud.dart';
+import 'package:nextcloud_test/nextcloud_test.dart';
 import 'package:test/test.dart';
 
-import 'helper.dart';
-
 void main() {
   group(
     'dashboard',
     () {
       late DockerContainer container;
-      late TestNextcloudClient client;
+      late NextcloudClient client;
       setUp(() async {
-        container = await getDockerContainer();
-        client = await getTestClient(container);
+        container = await DockerContainer.create();
+        client = await TestNextcloudClient.create(container);
       });
       tearDown(() => container.destroy());
 
diff --git a/packages/nextcloud/test/helper.dart b/packages/nextcloud/test/helper.dart
deleted file mode 100644
index 20c93025..00000000
--- a/packages/nextcloud/test/helper.dart
+++ /dev/null
@@ -1,227 +0,0 @@
-import 'dart:async';
-import 'dart:convert';
-import 'dart:math';
-
-import 'package:nextcloud/core.dart' as core;
-import 'package:nextcloud/nextcloud.dart';
-import 'package:process_run/cmd_run.dart';
-import 'package:test/test.dart';
-import 'package:universal_io/io.dart';
-
-const retryCount = 3;
-const timeout = Timeout(Duration(seconds: 30));
-
-class DockerContainer {
-  DockerContainer({
-    required this.id,
-    required this.port,
-  });
-
-  final String id;
-
-  final int port;
-
-  Future<void> runOccCommand(final List<String> args) async {
-    final result = await runExecutableArguments(
-      'docker',
-      [
-        'exec',
-        id,
-        'php',
-        '-f',
-        'occ',
-        ...args,
-      ],
-      stdout: stdout,
-      stderr: stderr,
-    );
-    if (result.exitCode != 0) {
-      throw Exception('Failed to run occ command');
-    }
-  }
-
-  void destroy() => unawaited(
-        runExecutableArguments(
-          'docker',
-          [
-            'kill',
-            id,
-          ],
-        ),
-      );
-
-  Future<String> serverLogs() async => (await runExecutableArguments(
-        'docker',
-        [
-          'logs',
-          id,
-        ],
-        stdoutEncoding: utf8,
-      ))
-          .stdout as String;
-
-  Future<String> nextcloudLogs() async => (await runExecutableArguments(
-        'docker',
-        [
-          'exec',
-          id,
-          'cat',
-          'data/nextcloud.log',
-        ],
-        stdoutEncoding: utf8,
-      ))
-          .stdout as String;
-
-  Future<String> allLogs() async => '${await serverLogs()}\n\n${await nextcloudLogs()}';
-}
-
-class TestNextcloudClient extends NextcloudClient {
-  TestNextcloudClient(
-    super.baseURL, {
-    super.loginName,
-    super.password,
-    super.appPassword,
-    super.language,
-    super.appType,
-    super.userAgentOverride,
-    super.cookieJar,
-  });
-}
-
-Future<TestNextcloudClient> getTestClient(
-  final DockerContainer container, {
-  final String? username = 'user1',
-  final AppType appType = AppType.unknown,
-  final String? userAgentOverride,
-}) async {
-  String? appPassword;
-  if (username != null) {
-    final inputStream = StreamController<List<int>>();
-    final process = runExecutableArguments(
-      'docker',
-      [
-        'exec',
-        '-i',
-        container.id,
-        'php',
-        '-f',
-        'occ',
-        'user:add-app-password',
-        username,
-      ],
-      stdin: inputStream.stream,
-    );
-    inputStream.add(utf8.encode(username));
-    await inputStream.close();
-
-    final result = await process;
-    if (result.exitCode != 0) {
-      throw Exception('Failed to run generate app password command\n${result.stderr}\n${result.stdout}');
-    }
-    appPassword = (result.stdout as String).split('\n')[1];
-  }
-
-  final client = TestNextcloudClient(
-    Uri(
-      scheme: 'http',
-      host: 'localhost',
-      port: container.port,
-    ),
-    loginName: username,
-    password: username,
-    appPassword: appPassword,
-    appType: appType,
-    userAgentOverride: userAgentOverride,
-    cookieJar: CookieJar(),
-  );
-
-  var i = 0;
-  while (true) {
-    try {
-      await client.core.getStatus();
-      break;
-    } catch (error) {
-      if (error is HttpException || error is DynamiteApiException) {
-        i++;
-        await Future<void>.delayed(const Duration(milliseconds: 100));
-        if (i >= 300) {
-          throw TimeoutException('Failed to get the status of the Server. $error');
-        }
-      } else {
-        rethrow;
-      }
-    }
-  }
-
-  return client;
-}
-
-Future<DockerContainer> getDockerContainer() async {
-  const dockerImageName = 'ghcr.io/nextcloud/neon/dev';
-
-  var result = await runExecutableArguments(
-    'docker',
-    [
-      'images',
-      '-q',
-      dockerImageName,
-    ],
-  );
-  if (result.exitCode != 0) {
-    throw Exception('Querying docker image failed: ${result.stderr}');
-  }
-  if (result.stdout.toString().isEmpty) {
-    throw Exception('Missing docker image $dockerImageName. Please build it using ./tool/build-dev-container.sh');
-  }
-
-  late int port;
-  while (true) {
-    port = randomPort();
-    result = await runExecutableArguments(
-      'docker',
-      [
-        'run',
-        '--rm',
-        '-d',
-        '--add-host',
-        'host.docker.internal:host-gateway',
-        '-p',
-        '$port:80',
-        dockerImageName,
-      ],
-    );
-    // 125 means the docker run command itself has failed which indicated the port is already used
-    if (result.exitCode != 125) {
-      break;
-    }
-  }
-
-  if (result.exitCode != 0) {
-    throw Exception('Failed to run docker container: ${result.stderr}');
-  }
-
-  return DockerContainer(
-    id: result.stdout.toString().replaceAll('\n', ''),
-    port: port,
-  );
-}
-
-class TestNextcloudUser {
-  TestNextcloudUser(
-    this.username,
-    this.password, {
-    this.displayName,
-  });
-
-  final String username;
-  final String password;
-  final String? displayName;
-}
-
-int randomPort() => 1024 + Random().nextInt(65535 - 1024);
-
-void expectDateInReasonableTimeRange(final DateTime actual, final DateTime expected) {
-  const duration = Duration(seconds: 10);
-  expect(actual.isAfter(expected.subtract(duration)), isTrue);
-  expect(actual.isBefore(expected.add(duration)), isTrue);
-}
diff --git a/packages/nextcloud/test/news_test.dart b/packages/nextcloud/test/news_test.dart
index 6e73b2a4..0f469985 100644
--- a/packages/nextcloud/test/news_test.dart
+++ b/packages/nextcloud/test/news_test.dart
@@ -2,19 +2,18 @@ import 'dart:async';
 
 import 'package:nextcloud/news.dart' as news;
 import 'package:nextcloud/nextcloud.dart';
+import 'package:nextcloud_test/nextcloud_test.dart';
 import 'package:test/test.dart';
 
-import 'helper.dart';
-
 void main() {
   group(
     'news',
     () {
       late DockerContainer container;
-      late TestNextcloudClient client;
+      late NextcloudClient client;
       setUp(() async {
-        container = await getDockerContainer();
-        client = await getTestClient(container);
+        container = await DockerContainer.create();
+        client = await TestNextcloudClient.create(container);
       });
       tearDown(() => container.destroy());
 
diff --git a/packages/nextcloud/test/notes_test.dart b/packages/nextcloud/test/notes_test.dart
index 795bd521..7f4bc8bc 100644
--- a/packages/nextcloud/test/notes_test.dart
+++ b/packages/nextcloud/test/notes_test.dart
@@ -1,19 +1,18 @@
 import 'package:nextcloud/core.dart' as core;
 import 'package:nextcloud/nextcloud.dart';
 import 'package:nextcloud/notes.dart' as notes;
+import 'package:nextcloud_test/nextcloud_test.dart';
 import 'package:test/test.dart';
 
-import 'helper.dart';
-
 void main() {
   group(
     'notes',
     () {
       late DockerContainer container;
-      late TestNextcloudClient client;
+      late NextcloudClient client;
       setUp(() async {
-        container = await getDockerContainer();
-        client = await getTestClient(container);
+        container = await DockerContainer.create();
+        client = await TestNextcloudClient.create(container);
       });
       tearDown(() => container.destroy());
 
diff --git a/packages/nextcloud/test/notifications_test.dart b/packages/nextcloud/test/notifications_test.dart
index 0520bf84..2fbe8740 100644
--- a/packages/nextcloud/test/notifications_test.dart
+++ b/packages/nextcloud/test/notifications_test.dart
@@ -1,17 +1,17 @@
 import 'dart:async';
 
+import 'package:nextcloud/nextcloud.dart';
 import 'package:nextcloud/notifications.dart';
+import 'package:nextcloud_test/nextcloud_test.dart';
 import 'package:test/test.dart';
 
-import 'helper.dart';
-
 void main() {
   group('notifications', () {
     late DockerContainer container;
-    late TestNextcloudClient client;
+    late NextcloudClient client;
     setUp(() async {
-      container = await getDockerContainer();
-      client = await getTestClient(
+      container = await DockerContainer.create();
+      client = await TestNextcloudClient.create(
         container,
         username: 'admin',
       );
@@ -42,7 +42,10 @@ void main() {
         expect(response.ocs.data[0].notificationId, 2);
         expect(response.ocs.data[0].app, 'admin_notifications');
         expect(response.ocs.data[0].user, 'admin');
-        expectDateInReasonableTimeRange(DateTime.parse(response.ocs.data[0].datetime), startTime);
+        expect(
+          DateTime.parse(response.ocs.data[0].datetime).millisecondsSinceEpoch,
+          closeTo(startTime.millisecondsSinceEpoch, 10E3),
+        );
         expect(response.ocs.data[0].objectType, 'admin_notifications');
         expect(response.ocs.data[0].objectId, isNotNull);
         expect(response.ocs.data[0].subject, '123');
@@ -67,7 +70,10 @@ void main() {
         expect(response.body.ocs.data.notificationId, 2);
         expect(response.body.ocs.data.app, 'admin_notifications');
         expect(response.body.ocs.data.user, 'admin');
-        expectDateInReasonableTimeRange(DateTime.parse(response.body.ocs.data.datetime), startTime);
+        expect(
+          DateTime.parse(response.body.ocs.data.datetime).millisecondsSinceEpoch,
+          closeTo(startTime.millisecondsSinceEpoch, 10E3),
+        );
         expect(response.body.ocs.data.objectType, 'admin_notifications');
         expect(response.body.ocs.data.objectId, isNotNull);
         expect(response.body.ocs.data.subject, '123');
@@ -101,10 +107,10 @@ void main() {
 
     group('Push', () {
       late DockerContainer container;
-      late TestNextcloudClient client;
+      late NextcloudClient client;
       setUp(() async {
-        container = await getDockerContainer();
-        client = await getTestClient(
+        container = await DockerContainer.create();
+        client = await TestNextcloudClient.create(
           container,
           username: 'admin',
         );
diff --git a/packages/nextcloud/test/provisioning_api_test.dart b/packages/nextcloud/test/provisioning_api_test.dart
index 6f175ced..41d60519 100644
--- a/packages/nextcloud/test/provisioning_api_test.dart
+++ b/packages/nextcloud/test/provisioning_api_test.dart
@@ -1,17 +1,17 @@
+import 'package:nextcloud/nextcloud.dart';
 import 'package:nextcloud/provisioning_api.dart';
+import 'package:nextcloud_test/nextcloud_test.dart';
 import 'package:test/test.dart';
 
-import 'helper.dart';
-
 void main() {
   group(
     'provisioning_api',
     () {
       late DockerContainer container;
-      late TestNextcloudClient client;
+      late NextcloudClient client;
       setUp(() async {
-        container = await getDockerContainer();
-        client = await getTestClient(
+        container = await DockerContainer.create();
+        client = await TestNextcloudClient.create(
           container,
           username: 'admin',
         );
diff --git a/packages/nextcloud/test/settings_test.dart b/packages/nextcloud/test/settings_test.dart
index 9ad42508..bb1fc9b0 100644
--- a/packages/nextcloud/test/settings_test.dart
+++ b/packages/nextcloud/test/settings_test.dart
@@ -1,19 +1,19 @@
 import 'dart:convert';
 
+import 'package:nextcloud/nextcloud.dart';
 import 'package:nextcloud/settings.dart';
+import 'package:nextcloud_test/nextcloud_test.dart';
 import 'package:test/test.dart';
 
-import 'helper.dart';
-
 void main() {
   group(
     'settings',
     () {
       late DockerContainer container;
-      late TestNextcloudClient client;
+      late NextcloudClient client;
       setUp(() async {
-        container = await getDockerContainer();
-        client = await getTestClient(
+        container = await DockerContainer.create();
+        client = await TestNextcloudClient.create(
           container,
           username: 'admin',
         );
diff --git a/packages/nextcloud/test/spreed_test.dart b/packages/nextcloud/test/spreed_test.dart
index b1774301..341d688b 100644
--- a/packages/nextcloud/test/spreed_test.dart
+++ b/packages/nextcloud/test/spreed_test.dart
@@ -3,20 +3,20 @@ import 'dart:convert';
 
 import 'package:built_value/json_object.dart';
 import 'package:nextcloud/core.dart' as core;
+import 'package:nextcloud/nextcloud.dart';
 import 'package:nextcloud/spreed.dart' as spreed;
+import 'package:nextcloud_test/nextcloud_test.dart';
 import 'package:test/test.dart';
 
-import 'helper.dart';
-
 void main() {
   group(
     'spreed',
     () {
       late DockerContainer container;
-      late TestNextcloudClient client1;
+      late NextcloudClient client1;
       setUp(() async {
-        container = await getDockerContainer();
-        client1 = await getTestClient(container);
+        container = await DockerContainer.create();
+        client1 = await TestNextcloudClient.create(container);
       });
       tearDown(() => container.destroy());
 
@@ -185,10 +185,7 @@ void main() {
           expect(response.body.ocs.data!.actorType, spreed.ActorType.users.name);
           expect(response.body.ocs.data!.actorId, 'user1');
           expect(response.body.ocs.data!.actorDisplayName, 'User One');
-          expectDateInReasonableTimeRange(
-            DateTime.fromMillisecondsSinceEpoch(response.body.ocs.data!.timestamp * 1000),
-            startTime,
-          );
+          expect(response.body.ocs.data!.timestamp * 1000, closeTo(startTime.millisecondsSinceEpoch, 10E3));
           expect(response.body.ocs.data!.message, 'bla');
           expect(response.body.ocs.data!.messageType, spreed.MessageType.comment.name);
         });
@@ -229,10 +226,7 @@ void main() {
             expect(response.body.ocs.data[0].actorType, spreed.ActorType.users.name);
             expect(response.body.ocs.data[0].actorId, 'user1');
             expect(response.body.ocs.data[0].actorDisplayName, 'User One');
-            expectDateInReasonableTimeRange(
-              DateTime.fromMillisecondsSinceEpoch(response.body.ocs.data[0].timestamp * 1000),
-              startTime,
-            );
+            expect(response.body.ocs.data[0].timestamp * 1000, closeTo(startTime.millisecondsSinceEpoch, 10E3));
             expect(response.body.ocs.data[0].message, '123');
             expect(response.body.ocs.data[0].messageType, spreed.MessageType.comment.name);
 
@@ -240,10 +234,7 @@ void main() {
             expect(response.body.ocs.data[0].parent!.actorType, spreed.ActorType.users.name);
             expect(response.body.ocs.data[0].parent!.actorId, 'user1');
             expect(response.body.ocs.data[0].parent!.actorDisplayName, 'User One');
-            expectDateInReasonableTimeRange(
-              DateTime.fromMillisecondsSinceEpoch(response.body.ocs.data[0].parent!.timestamp * 1000),
-              startTime,
-            );
+            expect(response.body.ocs.data[0].parent!.timestamp * 1000, closeTo(startTime.millisecondsSinceEpoch, 10E3));
             expect(response.body.ocs.data[0].parent!.message, 'bla');
             expect(response.body.ocs.data[0].parent!.messageType, spreed.MessageType.comment.name);
 
@@ -251,10 +242,7 @@ void main() {
             expect(response.body.ocs.data[1].actorType, spreed.ActorType.users.name);
             expect(response.body.ocs.data[1].actorId, 'user1');
             expect(response.body.ocs.data[1].actorDisplayName, 'User One');
-            expectDateInReasonableTimeRange(
-              DateTime.fromMillisecondsSinceEpoch(response.body.ocs.data[1].timestamp * 1000),
-              startTime,
-            );
+            expect(response.body.ocs.data[1].timestamp * 1000, closeTo(startTime.millisecondsSinceEpoch, 10E3));
             expect(response.body.ocs.data[1].message, 'bla');
             expect(response.body.ocs.data[1].messageType, spreed.MessageType.comment.name);
 
@@ -262,10 +250,7 @@ void main() {
             expect(response.body.ocs.data[2].actorType, spreed.ActorType.users.name);
             expect(response.body.ocs.data[2].actorId, 'user1');
             expect(response.body.ocs.data[2].actorDisplayName, 'User One');
-            expectDateInReasonableTimeRange(
-              DateTime.fromMillisecondsSinceEpoch(response.body.ocs.data[2].timestamp * 1000),
-              startTime,
-            );
+            expect(response.body.ocs.data[2].timestamp * 1000, closeTo(startTime.millisecondsSinceEpoch, 10E3));
             expect(response.body.ocs.data[2].message, 'You created the conversation');
             expect(response.body.ocs.data[2].systemMessage, 'conversation_created');
             expect(response.body.ocs.data[2].messageType, spreed.MessageType.system.name);
@@ -302,10 +287,7 @@ void main() {
             expect(response.body.ocs.data[0].actorType, spreed.ActorType.users.name);
             expect(response.body.ocs.data[0].actorId, 'user1');
             expect(response.body.ocs.data[0].actorDisplayName, 'User One');
-            expectDateInReasonableTimeRange(
-              DateTime.fromMillisecondsSinceEpoch(response.body.ocs.data[0].timestamp * 1000),
-              startTime,
-            );
+            expect(response.body.ocs.data[0].timestamp * 1000, closeTo(startTime.millisecondsSinceEpoch, 10E3));
             expect(response.body.ocs.data[0].message, '123');
             expect(response.body.ocs.data[0].messageType, spreed.MessageType.comment.name);
           });
@@ -379,7 +361,7 @@ void main() {
           final room1 = (await client1.spreed.room.joinRoom(token: room.token)).body.ocs.data;
           await client1.spreed.call.joinCall(token: room.token);
 
-          final client2 = await getTestClient(
+          final client2 = await TestNextcloudClient.create(
             container,
             username: 'user2',
           );
diff --git a/packages/nextcloud/test/uppush_test.dart b/packages/nextcloud/test/uppush_test.dart
index 2ecd3e7f..a611cd48 100644
--- a/packages/nextcloud/test/uppush_test.dart
+++ b/packages/nextcloud/test/uppush_test.dart
@@ -1,17 +1,17 @@
+import 'package:nextcloud/nextcloud.dart';
 import 'package:nextcloud/uppush.dart';
+import 'package:nextcloud_test/nextcloud_test.dart';
 import 'package:test/test.dart';
 
-import 'helper.dart';
-
 void main() {
   group(
     'uppush',
     () {
       late DockerContainer container;
-      late TestNextcloudClient client;
+      late NextcloudClient client;
       setUp(() async {
-        container = await getDockerContainer();
-        client = await getTestClient(
+        container = await DockerContainer.create();
+        client = await TestNextcloudClient.create(
           container,
           username: 'admin',
         );
diff --git a/packages/nextcloud/test/user_status_test.dart b/packages/nextcloud/test/user_status_test.dart
index b53905f9..21cce042 100644
--- a/packages/nextcloud/test/user_status_test.dart
+++ b/packages/nextcloud/test/user_status_test.dart
@@ -1,17 +1,17 @@
+import 'package:nextcloud/nextcloud.dart';
 import 'package:nextcloud/user_status.dart' as user_status;
+import 'package:nextcloud_test/nextcloud_test.dart';
 import 'package:test/test.dart';
 
-import 'helper.dart';
-
 void main() {
   group(
     'user_status',
     () {
       late DockerContainer container;
-      late TestNextcloudClient client;
+      late NextcloudClient client;
       setUp(() async {
-        container = await getDockerContainer();
-        client = await getTestClient(container);
+        container = await DockerContainer.create();
+        client = await TestNextcloudClient.create(container);
       });
       tearDown(() => container.destroy());
 
diff --git a/packages/nextcloud/test/webdav_test.dart b/packages/nextcloud/test/webdav_test.dart
index 2155aca1..fbbf1f06 100644
--- a/packages/nextcloud/test/webdav_test.dart
+++ b/packages/nextcloud/test/webdav_test.dart
@@ -3,11 +3,10 @@ import 'dart:math';
 import 'dart:typed_data';
 
 import 'package:nextcloud/nextcloud.dart';
+import 'package:nextcloud_test/nextcloud_test.dart';
 import 'package:test/test.dart';
 import 'package:universal_io/io.dart';
 
-import 'helper.dart';
-
 void main() {
   group('constructUri', () {
     for (final values in [
@@ -139,11 +138,11 @@ void main() {
     'webdav',
     () {
       late DockerContainer container;
-      late TestNextcloudClient client;
+      late NextcloudClient client;
 
       setUp(() async {
-        container = await getDockerContainer();
-        client = await getTestClient(container);
+        container = await DockerContainer.create();
+        client = await TestNextcloudClient.create(container);
       });
       tearDown(() => container.destroy());
 
@@ -281,12 +280,18 @@ void main() {
         expect(response.isCollection, isTrue);
         expect(response.mimeType, isNull);
         expect(response.size, data.lengthInBytes);
-        expectDateInReasonableTimeRange(response.lastModified!, DateTime.now());
+        expect(
+          response.lastModified!.millisecondsSinceEpoch,
+          closeTo(DateTime.now().millisecondsSinceEpoch, 10E3),
+        );
         expect(response.name, 'test');
         expect(response.isDirectory, isTrue);
 
         expect(response.props.davgetcontenttype, isNull);
-        expectDateInReasonableTimeRange(webdavDateFormat.parseUtc(response.props.davgetlastmodified!), DateTime.now());
+        expect(
+          webdavDateFormat.parseUtc(response.props.davgetlastmodified!).millisecondsSinceEpoch,
+          closeTo(DateTime.now().millisecondsSinceEpoch, 10E3),
+        );
         expect(response.props.davresourcetype!.collection, isNotNull);
         expect(response.props.ocsize, data.lengthInBytes);
       });
@@ -355,8 +360,8 @@ void main() {
             .prop;
         expect(props.ocfavorite, 1);
         expect(webdavDateFormat.parseUtc(props.davgetlastmodified!), lastModifiedDate);
-        expect(DateTime.fromMillisecondsSinceEpoch(props.nccreationtime! * 1000).isAtSameMomentAs(createdDate), isTrue);
-        expectDateInReasonableTimeRange(DateTime.fromMillisecondsSinceEpoch(props.ncuploadtime! * 1000), uploadTime);
+        expect(props.nccreationtime! * 1000, createdDate.millisecondsSinceEpoch);
+        expect(props.ncuploadtime! * 1000, closeTo(uploadTime.millisecondsSinceEpoch, 10E3));
       });
 
       test('Remove properties', () async {
diff --git a/packages/nextcloud_test/LICENSE b/packages/nextcloud_test/LICENSE
new file mode 120000
index 00000000..af8c58b1
--- /dev/null
+++ b/packages/nextcloud_test/LICENSE
@@ -0,0 +1 @@
+../../assets/AGPL-3.0.txt
\ No newline at end of file
diff --git a/packages/nextcloud_test/README.md b/packages/nextcloud_test/README.md
new file mode 100644
index 00000000..6dcbca86
--- /dev/null
+++ b/packages/nextcloud_test/README.md
@@ -0,0 +1,3 @@
+# nextcloud_test
+
+A helper package for running tests in the [nextcloud](../nextcloud) package.
diff --git a/packages/nextcloud_test/analysis_options.yaml b/packages/nextcloud_test/analysis_options.yaml
new file mode 100644
index 00000000..4db3c296
--- /dev/null
+++ b/packages/nextcloud_test/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:neon_lints/dart.yaml
diff --git a/tool/Dockerfile.dev b/packages/nextcloud_test/docker/Dockerfile
similarity index 98%
rename from tool/Dockerfile.dev
rename to packages/nextcloud_test/docker/Dockerfile
index b9b4571b..875194f8 100644
--- a/tool/Dockerfile.dev
+++ b/packages/nextcloud_test/docker/Dockerfile
@@ -18,7 +18,7 @@ RUN (sh /entrypoint.sh php -S 0.0.0.0:80 &) && \
     # Do not setup the demo user here
     for user in admin user1 user2; do curl -u "$user:$user" -H "ocs-apirequest: true" -s -o /dev/null http://localhost/ocs/v2.php/cloud/user; done
 
-COPY dev/static /usr/src/nextcloud/static
+COPY static /usr/src/nextcloud/static
 
 ENV PHP_CLI_SERVER_WORKERS=10
 CMD ["php", "-S", "0.0.0.0:80"]
diff --git a/tool/dev/static/nasa.xml b/packages/nextcloud_test/docker/static/nasa.xml
similarity index 100%
rename from tool/dev/static/nasa.xml
rename to packages/nextcloud_test/docker/static/nasa.xml
diff --git a/tool/dev/static/wikipedia.xml b/packages/nextcloud_test/docker/static/wikipedia.xml
similarity index 100%
rename from tool/dev/static/wikipedia.xml
rename to packages/nextcloud_test/docker/static/wikipedia.xml
diff --git a/packages/nextcloud_test/lib/nextcloud_test.dart b/packages/nextcloud_test/lib/nextcloud_test.dart
new file mode 100644
index 00000000..a7517d2b
--- /dev/null
+++ b/packages/nextcloud_test/lib/nextcloud_test.dart
@@ -0,0 +1,3 @@
+export 'src/defaults.dart';
+export 'src/docker_container.dart';
+export 'src/test_client.dart';
diff --git a/packages/nextcloud_test/lib/src/defaults.dart b/packages/nextcloud_test/lib/src/defaults.dart
new file mode 100644
index 00000000..d3d5ef9a
--- /dev/null
+++ b/packages/nextcloud_test/lib/src/defaults.dart
@@ -0,0 +1,7 @@
+import 'package:test/test.dart';
+
+/// Default retry count for test groups.
+const retryCount = 3;
+
+/// Default timeout for test groups.
+const timeout = Timeout(Duration(seconds: 30));
diff --git a/packages/nextcloud_test/lib/src/docker_container.dart b/packages/nextcloud_test/lib/src/docker_container.dart
new file mode 100644
index 00000000..752410e6
--- /dev/null
+++ b/packages/nextcloud_test/lib/src/docker_container.dart
@@ -0,0 +1,112 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:math';
+
+import 'package:process_run/process_run.dart';
+
+int _randomPort() => 1024 + Random().nextInt(65535 - 1024);
+
+/// Represents a docker container on the system.
+class DockerContainer {
+  DockerContainer._({
+    required this.id,
+    required this.port,
+  });
+
+  /// Creates a new docker container and returns its representation.
+  static Future<DockerContainer> create() async {
+    const dockerImageName = 'ghcr.io/nextcloud/neon/dev';
+
+    var result = await runExecutableArguments(
+      'docker',
+      [
+        'images',
+        '-q',
+        dockerImageName,
+      ],
+    );
+    if (result.exitCode != 0) {
+      throw Exception('Querying docker image failed: ${result.stderr}');
+    }
+    if (result.stdout.toString().isEmpty) {
+      throw Exception('Missing docker image $dockerImageName. Please build it using ./tool/build-dev-container.sh');
+    }
+
+    late int port;
+    while (true) {
+      port = _randomPort();
+      result = await runExecutableArguments(
+        'docker',
+        [
+          'run',
+          '--rm',
+          '-d',
+          '--add-host',
+          'host.docker.internal:host-gateway',
+          '-p',
+          '$port:80',
+          dockerImageName,
+        ],
+      );
+      // 125 means the docker run command itself has failed which indicated the port is already used
+      if (result.exitCode != 125) {
+        break;
+      }
+    }
+
+    if (result.exitCode != 0) {
+      throw Exception('Failed to run docker container: ${result.stderr}');
+    }
+
+    return DockerContainer._(
+      id: result.stdout.toString().replaceAll('\n', ''),
+      port: port,
+    );
+  }
+
+  /// ID of the docker container.
+  final String id;
+
+  /// Assigned port of docker container.
+  final int port;
+
+  /// Removes the docker container from the system.
+  void destroy() => unawaited(
+        runExecutableArguments(
+          'docker',
+          [
+            'kill',
+            id,
+          ],
+        ),
+      );
+
+  /// Reads the server logs.
+  Future<String> serverLogs() async => (await runExecutableArguments(
+        'docker',
+        [
+          'logs',
+          id,
+        ],
+        stdoutEncoding: utf8,
+      ))
+          .stdout as String;
+
+  /// Reads the Nextcloud logs.
+  Future<String> nextcloudLogs() async => (await runExecutableArguments(
+        'docker',
+        [
+          'exec',
+          id,
+          'cat',
+          'data/nextcloud.log',
+        ],
+        stdoutEncoding: utf8,
+      ))
+          .stdout as String;
+
+  /// Reads all logs.
+  ///
+  /// Combines the output of [serverLogs] and [nextcloudLogs].
+  Future<String> allLogs() async => '${await serverLogs()}\n\n${await nextcloudLogs()}';
+}
diff --git a/packages/nextcloud_test/lib/src/test_client.dart b/packages/nextcloud_test/lib/src/test_client.dart
new file mode 100644
index 00000000..a422dc5d
--- /dev/null
+++ b/packages/nextcloud_test/lib/src/test_client.dart
@@ -0,0 +1,79 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:nextcloud/core.dart';
+import 'package:nextcloud/nextcloud.dart';
+import 'package:nextcloud_test/src/docker_container.dart';
+import 'package:process_run/process_run.dart';
+import 'package:universal_io/io.dart';
+
+/// An extension for creating [NextcloudClient]s based on [DockerContainer]s.
+extension TestNextcloudClient on NextcloudClient {
+  /// Creates a new [NextcloudClient] for a given [container] and [username].
+  ///
+  /// It is expected that the password of the user matches the its [username].
+  /// This is the case for the available test docker containers.
+  static Future<NextcloudClient> create(
+    final DockerContainer container, {
+    final String? username = 'user1',
+  }) async {
+    String? appPassword;
+    if (username != null) {
+      final inputStream = StreamController<List<int>>();
+      final process = runExecutableArguments(
+        'docker',
+        [
+          'exec',
+          '-i',
+          container.id,
+          'php',
+          '-f',
+          'occ',
+          'user:add-app-password',
+          username,
+        ],
+        stdin: inputStream.stream,
+      );
+      inputStream.add(utf8.encode(username));
+      await inputStream.close();
+
+      final result = await process;
+      if (result.exitCode != 0) {
+        throw Exception('Failed to run generate app password command\n${result.stderr}\n${result.stdout}');
+      }
+      appPassword = (result.stdout as String).split('\n')[1];
+    }
+
+    final client = NextcloudClient(
+      Uri(
+        scheme: 'http',
+        host: 'localhost',
+        port: container.port,
+      ),
+      loginName: username,
+      password: username,
+      appPassword: appPassword,
+      cookieJar: CookieJar(),
+    );
+
+    var i = 0;
+    while (true) {
+      try {
+        await client.core.getStatus();
+        break;
+      } catch (error) {
+        if (error is HttpException || error is DynamiteApiException) {
+          i++;
+          await Future<void>.delayed(const Duration(milliseconds: 100));
+          if (i >= 300) {
+            throw TimeoutException('Failed to get the status of the Server. $error');
+          }
+        } else {
+          rethrow;
+        }
+      }
+    }
+
+    return client;
+  }
+}
diff --git a/packages/nextcloud_test/pubspec.yaml b/packages/nextcloud_test/pubspec.yaml
new file mode 100644
index 00000000..d7d13a07
--- /dev/null
+++ b/packages/nextcloud_test/pubspec.yaml
@@ -0,0 +1,21 @@
+name: nextcloud_test
+version: 1.0.0
+publish_to: none
+
+environment:
+  sdk: '>=3.1.0 <4.0.0'
+
+dependencies:
+  nextcloud:
+    git:
+      url: https://github.com/nextcloud/neon
+      path: packages/nextcloud
+  process_run: ^0.13.0
+  test: ^1.24.0
+  universal_io: ^2.0.0
+
+dev_dependencies:
+  neon_lints:
+    git:
+      url: https://github.com/nextcloud/neon
+      path: packages/neon_lints
diff --git a/packages/nextcloud_test/pubspec_overrides.yaml b/packages/nextcloud_test/pubspec_overrides.yaml
new file mode 100644
index 00000000..067c89d1
--- /dev/null
+++ b/packages/nextcloud_test/pubspec_overrides.yaml
@@ -0,0 +1,8 @@
+# melos_managed_dependency_overrides: dynamite_runtime,neon_lints,nextcloud
+dependency_overrides:
+  dynamite_runtime:
+    path: ../dynamite/dynamite_runtime
+  neon_lints:
+    path: ../neon_lints
+  nextcloud:
+    path: ../nextcloud
diff --git a/tool/build-dev-container.sh b/tool/build-dev-container.sh
index ce091348..49a9cb07 100755
--- a/tool/build-dev-container.sh
+++ b/tool/build-dev-container.sh
@@ -6,4 +6,4 @@ source tool/common.sh
 tag="$(image_tag "dev:latest")"
 
 # shellcheck disable=SC2046
-docker buildx build --tag "$tag" $(cache_build_args "$tag") -f - ./tool < tool/Dockerfile.dev
+docker buildx build --tag "$tag" $(cache_build_args "$tag") -f - ./packages/nextcloud_test/docker < packages/nextcloud_test/docker/Dockerfile