diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml
index 9327780f1d..629c71a92d 100644
--- a/mobile/analysis_options.yaml
+++ b/mobile/analysis_options.yaml
@@ -106,6 +106,8 @@ custom_lint:
         - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart
         - lib/services/auth.service.dart # on ApiException
         - test/services/auth.service_test.dart # on ApiException
+        # allow import from test
+        - test/**.dart
 
 dart_code_metrics:
   metrics:
diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart
index 47baf356b7..297a819b6a 100644
--- a/mobile/lib/models/search/search_filter.model.dart
+++ b/mobile/lib/models/search/search_filter.model.dart
@@ -266,8 +266,8 @@ class SearchFilter {
     AssetType? mediaType,
   }) {
     return SearchFilter(
-      context: context,
-      filename: filename,
+      context: context ?? this.context,
+      filename: filename ?? this.filename,
       people: people ?? this.people,
       location: location ?? this.location,
       camera: camera ?? this.camera,
diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart
index 01119485cf..9aca7fc118 100644
--- a/mobile/lib/pages/search/search.page.dart
+++ b/mobile/lib/pages/search/search.page.dart
@@ -441,19 +441,15 @@ class SearchPage extends HookConsumerWidget {
     }
 
     handleTextSubmitted(String value) {
-      if (value.isEmpty) {
-        return;
-      }
-
       if (isContextualSearch.value) {
         filter.value = filter.value.copyWith(
-          filename: null,
+          filename: '',
           context: value,
         );
       } else {
         filter.value = filter.value.copyWith(
           filename: value,
-          context: null,
+          context: '',
         );
       }
 
@@ -468,6 +464,7 @@ class SearchPage extends HookConsumerWidget {
           Padding(
             padding: const EdgeInsets.only(right: 14.0),
             child: IconButton(
+              key: const Key('contextual_search_button'),
               icon: isContextualSearch.value
                   ? const Icon(Icons.abc_rounded)
                   : const Icon(Icons.image_search_rounded),
@@ -496,6 +493,7 @@ class SearchPage extends HookConsumerWidget {
             ),
           ),
           child: TextField(
+            key: const Key('search_text_field'),
             controller: textSearchController,
             decoration: InputDecoration(
               contentPadding: prefilter != null
@@ -551,6 +549,7 @@ class SearchPage extends HookConsumerWidget {
             child: SizedBox(
               height: 50,
               child: ListView(
+                key: const Key('search_filter_chip_list'),
                 shrinkWrap: true,
                 scrollDirection: Axis.horizontal,
                 padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -580,6 +579,7 @@ class SearchPage extends HookConsumerWidget {
                     currentFilter: dateRangeCurrentFilterWidget.value,
                   ),
                   SearchFilterChip(
+                    key: const Key('media_type_chip'),
                     icon: Icons.video_collection_outlined,
                     onTap: showMediaTypePicker,
                     label: 'search_filter_media_type'.tr(),
diff --git a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart
index bda9335c77..fe938e16ed 100644
--- a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart
+++ b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart
@@ -53,6 +53,7 @@ class FilterBottomSheetScaffold extends StatelessWidget {
                 ),
                 const SizedBox(width: 8),
                 ElevatedButton(
+                  key: const Key('search_filter_apply'),
                   onPressed: () {
                     onSearch();
                     context.pop();
diff --git a/mobile/lib/widgets/search/search_filter/media_type_picker.dart b/mobile/lib/widgets/search/search_filter/media_type_picker.dart
index 350fce155d..495f4d007e 100644
--- a/mobile/lib/widgets/search/search_filter/media_type_picker.dart
+++ b/mobile/lib/widgets/search/search_filter/media_type_picker.dart
@@ -17,6 +17,7 @@ class MediaTypePicker extends HookWidget {
       shrinkWrap: true,
       children: [
         RadioListTile(
+          key: const Key("search_filter_media_type_all"),
           title: const Text("search_filter_media_type_all").tr(),
           value: AssetType.other,
           onChanged: (value) {
@@ -26,6 +27,7 @@ class MediaTypePicker extends HookWidget {
           groupValue: selectedMediaType.value,
         ),
         RadioListTile(
+          key: const Key("search_filter_media_type_image"),
           title: const Text("search_filter_media_type_image").tr(),
           value: AssetType.image,
           onChanged: (value) {
@@ -35,6 +37,7 @@ class MediaTypePicker extends HookWidget {
           groupValue: selectedMediaType.value,
         ),
         RadioListTile(
+          key: const Key("search_filter_media_type_video"),
           title: const Text("search_filter_media_type_video").tr(),
           value: AssetType.video,
           onChanged: (value) {
diff --git a/mobile/openapi/devtools_options.yaml b/mobile/openapi/devtools_options.yaml
new file mode 100644
index 0000000000..fa0b357c4f
--- /dev/null
+++ b/mobile/openapi/devtools_options.yaml
@@ -0,0 +1,3 @@
+description: This file stores settings for Dart & Flutter DevTools.
+documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
+extensions:
diff --git a/mobile/test/dto.mocks.dart b/mobile/test/dto.mocks.dart
new file mode 100644
index 0000000000..ed53fcdc90
--- /dev/null
+++ b/mobile/test/dto.mocks.dart
@@ -0,0 +1,6 @@
+import 'package:mocktail/mocktail.dart';
+import 'package:openapi/api.dart';
+
+class MockSmartSearchDto extends Mock implements SmartSearchDto {}
+
+class MockMetadataSearchDto extends Mock implements MetadataSearchDto {}
diff --git a/mobile/test/pages/search/search.page_test.dart b/mobile/test/pages/search/search.page_test.dart
new file mode 100644
index 0000000000..8cdf610433
--- /dev/null
+++ b/mobile/test/pages/search/search.page_test.dart
@@ -0,0 +1,189 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/entities/store.entity.dart';
+import 'package:immich_mobile/pages/search/search.page.dart';
+import 'package:immich_mobile/providers/api.provider.dart';
+import 'package:immich_mobile/providers/db.provider.dart';
+import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
+import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
+import 'package:isar/isar.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:openapi/api.dart';
+
+import '../../dto.mocks.dart';
+import '../../service.mocks.dart';
+import '../../test_utils.dart';
+import '../../widget_tester_extensions.dart';
+
+void main() {
+  late List<Override> overrides;
+  late Isar db;
+  late MockApiService mockApiService;
+  late MockSearchApi mockSearchApi;
+
+  setUpAll(() async {
+    TestUtils.init();
+    db = await TestUtils.initIsar();
+    Store.init(db);
+    mockApiService = MockApiService();
+    mockSearchApi = MockSearchApi();
+    when(() => mockApiService.searchApi).thenReturn(mockSearchApi);
+    registerFallbackValue(MockSmartSearchDto());
+    registerFallbackValue(MockMetadataSearchDto());
+    overrides = [
+      paginatedSearchRenderListProvider
+          .overrideWithValue(AsyncValue.data(RenderList.empty())),
+      dbProvider.overrideWithValue(db),
+      apiServiceProvider.overrideWithValue(mockApiService),
+    ];
+  });
+
+  final emptyTextSearch = isA<MetadataSearchDto>()
+      .having((s) => s.originalFileName, 'originalFileName', null);
+
+  testWidgets('contextual search with/without text', (tester) async {
+    await tester.pumpConsumerWidget(
+      const SearchPage(),
+      overrides: overrides,
+    );
+
+    await tester.pumpAndSettle();
+
+    expect(
+      find.byIcon(Icons.abc_rounded),
+      findsOneWidget,
+      reason: 'Should have contextual search icon',
+    );
+
+    final searchField = find.byKey(const Key('search_text_field'));
+    expect(searchField, findsOneWidget);
+
+    await tester.enterText(searchField, 'test');
+    await tester.testTextInput.receiveAction(TextInputAction.search);
+
+    var captured = verify(
+      () => mockSearchApi.searchSmart(captureAny()),
+    ).captured;
+
+    expect(
+      captured.first,
+      isA<SmartSearchDto>().having((s) => s.query, 'query', 'test'),
+    );
+
+    await tester.enterText(searchField, '');
+    await tester.testTextInput.receiveAction(TextInputAction.search);
+
+    captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured;
+    expect(captured.first, emptyTextSearch);
+  });
+
+  testWidgets('not contextual search with/without text', (tester) async {
+    await tester.pumpConsumerWidget(
+      const SearchPage(),
+      overrides: overrides,
+    );
+
+    await tester.pumpAndSettle();
+
+    await tester.tap(find.byKey(const Key('contextual_search_button')));
+
+    await tester.pumpAndSettle();
+
+    expect(
+      find.byIcon(Icons.image_search_rounded),
+      findsOneWidget,
+      reason: 'Should not have contextual search icon',
+    );
+
+    final searchField = find.byKey(const Key('search_text_field'));
+    expect(searchField, findsOneWidget);
+
+    await tester.enterText(searchField, 'test');
+    await tester.testTextInput.receiveAction(TextInputAction.search);
+
+    var captured = verify(
+      () => mockSearchApi.searchAssets(captureAny()),
+    ).captured;
+
+    expect(
+      captured.first,
+      isA<MetadataSearchDto>()
+          .having((s) => s.originalFileName, 'originalFileName', 'test'),
+    );
+
+    await tester.enterText(searchField, '');
+    await tester.testTextInput.receiveAction(TextInputAction.search);
+
+    captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured;
+    expect(captured.first, emptyTextSearch);
+  });
+
+  // COME BACK LATER
+  // testWidgets('contextual search with text combined with media type',
+  //     (tester) async {
+  //   await tester.pumpConsumerWidget(
+  //     const SearchPage(),
+  //     overrides: overrides,
+  //   );
+
+  //   await tester.pumpAndSettle();
+
+  //   expect(
+  //     find.byIcon(Icons.abc_rounded),
+  //     findsOneWidget,
+  //     reason: 'Should have contextual search icon',
+  //   );
+
+  //   final searchField = find.byKey(const Key('search_text_field'));
+  //   expect(searchField, findsOneWidget);
+
+  //   await tester.enterText(searchField, 'test');
+  //   await tester.testTextInput.receiveAction(TextInputAction.search);
+
+  //   var captured = verify(
+  //     () => mockSearchApi.searchSmart(captureAny()),
+  //   ).captured;
+
+  //   expect(
+  //     captured.first,
+  //     isA<SmartSearchDto>().having((s) => s.query, 'query', 'test'),
+  //   );
+
+  //   await tester.dragUntilVisible(
+  //     find.byKey(const Key('media_type_chip')),
+  //     find.byKey(const Key('search_filter_chip_list')),
+  //     const Offset(-100, 0),
+  //   );
+  //   await tester.pumpAndSettle();
+
+  //   await tester.tap(find.byKey(const Key('media_type_chip')));
+  //   await tester.pumpAndSettle();
+
+  //   await tester.tap(find.byKey(const Key('search_filter_media_type_image')));
+  //   await tester.pumpAndSettle();
+
+  //   await tester.tap(find.byKey(const Key('search_filter_apply')));
+  //   await tester.pumpAndSettle();
+
+  //   captured = verify(() => mockSearchApi.searchSmart(captureAny())).captured;
+
+  //   expect(
+  //     captured.first,
+  //     isA<SmartSearchDto>()
+  //         .having((s) => s.query, 'query', 'test')
+  //         .having((s) => s.type, 'type', AssetTypeEnum.IMAGE),
+  //   );
+
+  //   await tester.enterText(searchField, '');
+  //   await tester.testTextInput.receiveAction(TextInputAction.search);
+
+  //   captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured;
+  //   expect(
+  //     captured.first,
+  //     isA<MetadataSearchDto>()
+  //         .having((s) => s.originalFileName, 'originalFileName', null)
+  //         .having((s) => s.type, 'type', AssetTypeEnum.IMAGE),
+  //   );
+  // });
+}
diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart
index 507b4f281b..cc9d657e9e 100644
--- a/mobile/test/service.mocks.dart
+++ b/mobile/test/service.mocks.dart
@@ -5,6 +5,7 @@ import 'package:immich_mobile/services/network.service.dart';
 import 'package:immich_mobile/services/sync.service.dart';
 import 'package:immich_mobile/services/user.service.dart';
 import 'package:mocktail/mocktail.dart';
+import 'package:openapi/api.dart';
 
 class MockApiService extends Mock implements ApiService {}
 
@@ -17,3 +18,5 @@ class MockHashService extends Mock implements HashService {}
 class MockEntityService extends Mock implements EntityService {}
 
 class MockNetworkService extends Mock implements NetworkService {}
+
+class MockSearchApi extends Mock implements SearchApi {}