diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js
index cd24a503631352c66c0a3b68456605a0a9833d94..09d2e065e5875d362ae641577ecc43a6c61aa45b 100644
--- a/app/assets/javascripts/commons/vue.js
+++ b/app/assets/javascripts/commons/vue.js
@@ -9,4 +9,4 @@ if (process.env.NODE_ENV !== 'production') {
 Vue.use(GlFeatureFlagsPlugin);
 Vue.use(Translate);
 
-Vue.config.ignoredElements = ['gl-emoji'];
+Vue.config.ignoredElements = ['gl-emoji', 'copy-code'];
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index dff95bde26916adf825531e30aef207027f323c1..bfabac3123b7d88519f70e660838acc43b9b5c85 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -2,6 +2,7 @@
 import { GlLoadingIcon, GlPagination, GlSprintf, GlAlert } from '@gitlab/ui';
 import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
 import { mapState, mapGetters, mapActions } from 'vuex';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
 import api from '~/api';
 import {
   keysFor,
@@ -78,6 +79,8 @@ export default {
     GlPagination,
     GlSprintf,
     GlAlert,
+    GenerateTestFileDrawer: () =>
+      import('ee_component/ai/components/generate_test_file_drawer.vue'),
   },
   mixins: [glFeatureFlagsMixin()],
   alerts: {
@@ -226,6 +229,7 @@ export default {
       'showWhitespace',
       'targetBranchName',
       'branchName',
+      'generateTestFilePath',
     ]),
     ...mapGetters('diffs', [
       'whichCollapsedTypes',
@@ -297,6 +301,9 @@ export default {
     fileReviews() {
       return reviewStatuses(this.diffFiles, this.mrReviews);
     },
+    resourceId() {
+      return convertToGraphQLId('MergeRequest', this.getNoteableData.id);
+    },
   },
   watch: {
     commit(newCommit, oldCommit) {
@@ -450,6 +457,7 @@ export default {
       'navigateToDiffFileIndex',
       'setFileByFile',
       'disableVirtualScroller',
+      'setGenerateTestFilePath',
     ]),
     ...mapActions('findingsDrawer', ['setDrawer']),
     closeDrawer() {
@@ -807,5 +815,11 @@ export default {
         </div>
       </div>
     </div>
+    <generate-test-file-drawer
+      v-if="getNoteableData.id"
+      :resource-id="resourceId"
+      :file-path="generateTestFilePath"
+      @close="() => setGenerateTestFilePath('')"
+    />
   </div>
 </template>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index c3a4897ce780777f5ec6d7f70a56086245d0eaae..e202e02fedd32303754619d6d88d88004baee784 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -52,6 +52,11 @@ export default {
     compareButtonLabel: __('Compare submodule commit revisions'),
     fileModeTooltip: __('File permissions'),
   },
+  inject: {
+    showGenerateTestFileButton: {
+      default: false,
+    },
+  },
   props: {
     discussionPath: {
       type: String,
@@ -227,6 +232,7 @@ export default {
       'setCurrentFileHash',
       'reviewFile',
       'setFileCollapsedByUser',
+      'setGenerateTestFilePath',
     ]),
     handleToggleFile() {
       this.$emit('toggleFile');
@@ -412,6 +418,12 @@ export default {
             <gl-icon name="ellipsis_v" class="mr-0" />
             <span class="sr-only">{{ $options.i18n.optionsDropdownTitle }}</span>
           </template>
+          <gl-dropdown-item
+            v-if="showGenerateTestFileButton"
+            @click="setGenerateTestFilePath(diffFile.new_path)"
+          >
+            {{ __('Generate test with AI') }}
+          </gl-dropdown-item>
           <gl-dropdown-item
             v-if="diffFile.replaced_view_path"
             ref="replacedFileButton"
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index ad7182024da241ec4df93f334a9bebf99b0570da..53c27632c4fadbffba1a82ddbd14f5ff33b46f8e 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -28,6 +28,7 @@ export default function initDiffsApp(store = notesStore) {
     apolloProvider,
     provide: {
       newCommentTemplatePath: dataset.newCommentTemplatePath,
+      showGenerateTestFileButton: parseBoolean(dataset.showGenerateTestFileButton),
     },
     data() {
       return {
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 312b02356a5ae2a39ddabd35e255f23b6f7f493b..fec5eabdd9f2f94965ea64ee474ac68d55f997b2 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -973,6 +973,7 @@ Input type: `AiActionInput`
 | <a id="mutationaiactionclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
 | <a id="mutationaiactionexplaincode"></a>`explainCode` | [`AiExplainCodeInput`](#aiexplaincodeinput) | Input for explain_code AI action. |
 | <a id="mutationaiactionexplainvulnerability"></a>`explainVulnerability` | [`AiExplainVulnerabilityInput`](#aiexplainvulnerabilityinput) | Input for explain_vulnerability AI action. |
+| <a id="mutationaiactiongeneratetestfile"></a>`generateTestFile` | [`GenerateTestFileInput`](#generatetestfileinput) | Input for generate_test_file AI action. |
 | <a id="mutationaiactionmarkupformat"></a>`markupFormat` | [`MarkupFormat`](#markupformat) | Indicates the response format. |
 | <a id="mutationaiactionsummarizecomments"></a>`summarizeComments` | [`AiSummarizeCommentsInput`](#aisummarizecommentsinput) | Input for summarize_comments AI action. |
 | <a id="mutationaiactiontanukibot"></a>`tanukiBot` | [`AiTanukiBotInput`](#aitanukibotinput) | Input for tanuki_bot AI action. |
@@ -26945,6 +26946,15 @@ Represents an escalation rule.
 | <a id="escalationruleinputstatus"></a>`status` | [`EscalationRuleStatus!`](#escalationrulestatus) | Status required to prevent the rule from activating. |
 | <a id="escalationruleinputusername"></a>`username` | [`String`](#string) | Username of the user to notify. |
 
+### `GenerateTestFileInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="generatetestfileinputfilepath"></a>`filePath` | [`String!`](#string) | File path to generate test files for. |
+| <a id="generatetestfileinputresourceid"></a>`resourceId` | [`AiModelID!`](#aimodelid) | Global ID of the resource to mutate. |
+
 ### `JiraUsersMappingInputType`
 
 #### Arguments
diff --git a/ee/app/assets/javascripts/ai/components/generate_test_file_drawer.vue b/ee/app/assets/javascripts/ai/components/generate_test_file_drawer.vue
new file mode 100644
index 0000000000000000000000000000000000000000..9ccc6ba8a3fd3ee43ccaf99f572e208528e071c8
--- /dev/null
+++ b/ee/app/assets/javascripts/ai/components/generate_test_file_drawer.vue
@@ -0,0 +1,143 @@
+<script>
+import { GlDrawer, GlBadge, GlSkeletonLoader, GlAlert } from '@gitlab/ui';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
+import aiResponseSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response.subscription.graphql';
+import testFileGeneratorMutation from '../graphql/test_file_generator.mutation.graphql';
+
+export default {
+  apollo: {
+    $subscribe: {
+      testFile: {
+        query: aiResponseSubscription,
+        variables() {
+          return {
+            resourceId: this.resourceId,
+            userId: convertToGraphQLId('User', window.gon.current_user_id), // eslint-disable-line @gitlab/require-i18n-strings
+          };
+        },
+        skip() {
+          return !this.opened;
+        },
+        result({ data }) {
+          const responseBody = data.aiCompletionResponse?.responseBody;
+
+          if (responseBody) {
+            const codeBlockRegex = /<pre(.*)><code>(.*)<\/code><\/pre>/gm;
+            const codeBlock = codeBlockRegex.exec(responseBody.replaceAll('\n', '\\n'));
+
+            if (codeBlock) {
+              this.generatedTest = codeBlock[0].replaceAll('\\n', '\n');
+              this.state = '';
+            } else {
+              this.state = 'unable';
+            }
+          }
+        },
+      },
+    },
+  },
+  directives: { SafeHtml },
+  components: {
+    GlDrawer,
+    GlBadge,
+    GlSkeletonLoader,
+    GlAlert,
+  },
+  props: {
+    resourceId: {
+      type: String,
+      required: true,
+    },
+    filePath: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      opened: false,
+      state: '',
+      generatedTest: '',
+    };
+  },
+  computed: {
+    drawerHeightOffset() {
+      return getContentWrapperHeight('.content-wrapper');
+    },
+  },
+  watch: {
+    filePath: {
+      handler(newVal) {
+        this.opened = Boolean(newVal);
+
+        if (this.opened) {
+          this.triggerMutation();
+        } else {
+          this.generatedTest = '';
+          this.state = '';
+        }
+      },
+      immediate: true,
+    },
+  },
+  methods: {
+    triggerMutation() {
+      this.state = 'loading';
+
+      this.$apollo.mutate({
+        mutation: testFileGeneratorMutation,
+        variables: {
+          resourceId: this.resourceId,
+          filePath: this.filePath,
+        },
+      });
+    },
+  },
+  DRAWER_Z_INDEX,
+};
+</script>
+
+<template>
+  <gl-drawer
+    :open="opened"
+    :header-height="drawerHeightOffset"
+    :z-index="$options.DRAWER_Z_INDEX"
+    @close="$emit('close')"
+  >
+    <template #title>
+      <div class="gl-display-flex">
+        <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">
+          {{ __('AI Generated Test File') }}
+        </h2>
+        <div>
+          <gl-badge variant="info" size="sm" class="gl-ml-3 gl-mt-2">{{
+            __('Experiment')
+          }}</gl-badge>
+        </div>
+      </div>
+    </template>
+    <div>
+      <div class="markdown-code-block gl-relative">
+        <div
+          v-if="state === 'loading'"
+          class="gl-border-1 gl-border-gray-100 gl-border-solid gl-p-4 gl-rounded-base gl-bg-gray-10"
+          data-testid="generate-test-loading-state"
+        >
+          <gl-skeleton-loader :lines="4" />
+        </div>
+        <gl-alert v-else-if="state === 'unable'" :dismissible="false">
+          {{ __('Unable to generate tests for specified file.') }}
+        </gl-alert>
+        <template v-else>
+          <div class="gl-relative markdown-code-block js-markdown-code">
+            <span v-safe-html="generatedTest" data-testid="generate-test-code"></span>
+            <copy-code />
+          </div>
+        </template>
+      </div>
+    </div>
+  </gl-drawer>
+</template>
diff --git a/ee/app/assets/javascripts/ai/graphql/test_file_generator.mutation.graphql b/ee/app/assets/javascripts/ai/graphql/test_file_generator.mutation.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..7c94ce8bf7f183ba4328d94dea221bca2898d443
--- /dev/null
+++ b/ee/app/assets/javascripts/ai/graphql/test_file_generator.mutation.graphql
@@ -0,0 +1,10 @@
+mutation generateTestFile($resourceId: AiModelID!, $filePath: String!) {
+  aiAction(
+    input: {
+      generateTestFile: { resourceId: $resourceId, filePath: $filePath }
+      markupFormat: HTML
+    }
+  ) {
+    errors
+  }
+}
diff --git a/ee/app/assets/javascripts/diffs/store/actions.js b/ee/app/assets/javascripts/diffs/store/actions.js
index adb6b16a13f8dd3a6cb463820c8731deeb059991..78e0e68bbd69de608a323cfa6984088e2dd68dd8 100644
--- a/ee/app/assets/javascripts/diffs/store/actions.js
+++ b/ee/app/assets/javascripts/diffs/store/actions.js
@@ -78,3 +78,6 @@ export const fetchCodequality = ({ commit, state, dispatch }) => {
     }
   });
 };
+
+export const setGenerateTestFilePath = ({ commit }, path) =>
+  commit(types.SET_GENERATE_TEST_FILE_PATH, path);
diff --git a/ee/app/assets/javascripts/diffs/store/modules/diff_state.js b/ee/app/assets/javascripts/diffs/store/modules/diff_state.js
index 3956c1813b3150cc0481ee08cbddbca1c6429e67..05254bec903cfb0a91e640aca85d0ab4606df402 100644
--- a/ee/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/ee/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -5,4 +5,5 @@ export default () => ({
 
   endpointCodequality: '',
   codequalityDiff: {},
+  generateTestFilePath: '',
 });
diff --git a/ee/app/assets/javascripts/diffs/store/mutation_types.js b/ee/app/assets/javascripts/diffs/store/mutation_types.js
index 4af2ff7f9de043c927273ce4d02334ff715c0035..91a030c5e003619dca37731d358146b5982f69e4 100644
--- a/ee/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/ee/app/assets/javascripts/diffs/store/mutation_types.js
@@ -1,2 +1,3 @@
 export const SET_CODEQUALITY_ENDPOINT = 'SET_CODEQUALITY_ENDPOINT';
 export const SET_CODEQUALITY_DATA = 'SET_CODEQUALITY_DATA';
+export const SET_GENERATE_TEST_FILE_PATH = 'SET_GENERATE_TEST_FILE_PATH';
diff --git a/ee/app/assets/javascripts/diffs/store/mutations.js b/ee/app/assets/javascripts/diffs/store/mutations.js
index d18311752d195ba0d34ff04399b12395a6e48ff4..9bcb6a99dd7bce81e81c25683bcebcebd4ea5eab 100644
--- a/ee/app/assets/javascripts/diffs/store/mutations.js
+++ b/ee/app/assets/javascripts/diffs/store/mutations.js
@@ -12,4 +12,8 @@ export default {
   [types.SET_CODEQUALITY_DATA](state, codequalityDiffData) {
     Object.assign(state, { codequalityDiff: codequalityDiffData });
   },
+
+  [types.SET_GENERATE_TEST_FILE_PATH](state, path) {
+    state.generateTestFilePath = path;
+  },
 };
diff --git a/ee/app/graphql/types/ai/generate_test_file_input_type.rb b/ee/app/graphql/types/ai/generate_test_file_input_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..14487baeaa7865eb5348123d4a4880f732329147
--- /dev/null
+++ b/ee/app/graphql/types/ai/generate_test_file_input_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+  module Ai
+    class GenerateTestFileInputType < BaseMethodInputType
+      graphql_name 'GenerateTestFileInput'
+
+      argument :file_path, GraphQL::Types::String,
+        required: true,
+        validates: { allow_blank: false },
+        description: 'File path to generate test files for.'
+    end
+  end
+end
diff --git a/ee/app/helpers/ee/merge_requests_helper.rb b/ee/app/helpers/ee/merge_requests_helper.rb
index 7fef8e8b98989ace1736167181b75d99ab4b8226..b491381b52340da25d75706cbe173ffa3b2d2cf5 100644
--- a/ee/app/helpers/ee/merge_requests_helper.rb
+++ b/ee/app/helpers/ee/merge_requests_helper.rb
@@ -21,7 +21,8 @@ def render_items_list(items, separator = "and")
     override :diffs_tab_pane_data
     def diffs_tab_pane_data(project, merge_request, params)
       super.merge(
-        endpoint_codequality: (codequality_mr_diff_reports_project_merge_request_path(project, merge_request, 'json') if project.licensed_feature_available?(:inline_codequality) && merge_request.has_codequality_mr_diff_report?)
+        endpoint_codequality: (codequality_mr_diff_reports_project_merge_request_path(project, merge_request, 'json') if project.licensed_feature_available?(:inline_codequality) && merge_request.has_codequality_mr_diff_report?),
+        show_generate_test_file_button: ::Llm::GenerateTestFileService.new(current_user, merge_request).valid?.to_s
       )
     end
   end
diff --git a/ee/app/models/gitlab_subscriptions/features.rb b/ee/app/models/gitlab_subscriptions/features.rb
index e5f6b2ac92190be16fd50b3ea6b4bc3561f318ab..5c83b261c64cf2a2910db441bb9cfdcdfb973fac 100644
--- a/ee/app/models/gitlab_subscriptions/features.rb
+++ b/ee/app/models/gitlab_subscriptions/features.rb
@@ -198,6 +198,7 @@ class Features
       evaluate_group_level_compliance_pipeline
       explain_code
       external_audit_events
+      generate_test_file
       git_abuse_rate_limit
       group_ci_cd_analytics
       group_level_compliance_dashboard
diff --git a/ee/app/services/llm/execute_method_service.rb b/ee/app/services/llm/execute_method_service.rb
index 3826a14b1d15185e87050f9bfd469b8c42255c64..26adc44ce07a60f9b19e40278f0c89a32cfc3f6e 100644
--- a/ee/app/services/llm/execute_method_service.rb
+++ b/ee/app/services/llm/execute_method_service.rb
@@ -8,7 +8,8 @@ class ExecuteMethodService < BaseService
       explain_vulnerability: ::Llm::ExplainVulnerabilityService,
       summarize_comments: Llm::GenerateSummaryService,
       explain_code: Llm::ExplainCodeService,
-      tanuki_bot: Llm::TanukiBotService
+      tanuki_bot: Llm::TanukiBotService,
+      generate_test_file: Llm::GenerateTestFileService
     }.freeze
 
     def initialize(user, resource, method, options = {})
diff --git a/ee/app/services/llm/generate_test_file_service.rb b/ee/app/services/llm/generate_test_file_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0c184839a84859bdac158c0a61355fd0025f80b2
--- /dev/null
+++ b/ee/app/services/llm/generate_test_file_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Llm
+  class GenerateTestFileService < BaseService
+    def valid?
+      super &&
+        Feature.enabled?(:generate_test_file, user) &&
+        resource.resource_parent.root_ancestor.licensed_feature_available?(:generate_test_file)
+    end
+
+    private
+
+    def perform
+      ::Llm::CompletionWorker.perform_async(user.id, resource.id, resource.class.name, :generate_test_file, options)
+
+      success
+    end
+  end
+end
diff --git a/ee/config/feature_flags/development/generate_test_file.yml b/ee/config/feature_flags/development/generate_test_file.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6647a5cef0b5ec52fab5959c5461d96797a8972b
--- /dev/null
+++ b/ee/config/feature_flags/development/generate_test_file.yml
@@ -0,0 +1,8 @@
+---
+name: generate_test_file
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118365
+rollout_issue_url:
+milestone: '16.0'
+type: development
+group: group::code review
+default_enabled: false
diff --git a/ee/lib/gitlab/llm/open_ai/completions/factory.rb b/ee/lib/gitlab/llm/open_ai/completions/factory.rb
index 85c5261dc9b2a10a58a488ddcacc17735acf6910..f0ead004b4e5ce895d0daca4e3f508ba82e753b3 100644
--- a/ee/lib/gitlab/llm/open_ai/completions/factory.rb
+++ b/ee/lib/gitlab/llm/open_ai/completions/factory.rb
@@ -21,6 +21,10 @@ class Factory
             tanuki_bot: {
               service_class: ::Gitlab::Llm::OpenAi::Completions::TanukiBot,
               prompt_class: ::Gitlab::Llm::OpenAi::Templates::TanukiBot
+            },
+            generate_test_file: {
+              service_class: ::Gitlab::Llm::OpenAi::Completions::GenerateTestFile,
+              prompt_class: ::Gitlab::Llm::OpenAi::Templates::GenerateTestFile
             }
           }.freeze
 
diff --git a/ee/lib/gitlab/llm/open_ai/completions/generate_test_file.rb b/ee/lib/gitlab/llm/open_ai/completions/generate_test_file.rb
new file mode 100644
index 0000000000000000000000000000000000000000..74af23782f7d136153d13d933d044d70ad76a7dc
--- /dev/null
+++ b/ee/lib/gitlab/llm/open_ai/completions/generate_test_file.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Llm
+    module OpenAi
+      module Completions
+        class GenerateTestFile
+          TOTAL_MODEL_TOKEN_LIMIT = 4000
+          OUTPUT_TOKEN_LIMIT = (TOTAL_MODEL_TOKEN_LIMIT * 0.25).to_i.freeze
+
+          def initialize(ai_prompt_class)
+            @ai_prompt_class = ai_prompt_class
+          end
+
+          def execute(user, merge_request, options)
+            return unless user
+            return unless merge_request
+            return unless merge_request.send_to_ai?
+
+            ai_options = ai_prompt_class.get_options(merge_request, options[:file_path])
+            ai_options[:max_tokens] = OUTPUT_TOKEN_LIMIT
+
+            ai_response = Gitlab::Llm::OpenAi::Client.new(user).chat(content: nil, **ai_options)
+
+            ::Gitlab::Llm::OpenAi::ResponseService.new(user, merge_request, ai_response, options: options).execute(
+              Gitlab::Llm::OpenAi::ResponseModifiers::Chat.new
+            )
+          end
+
+          private
+
+          attr_reader :ai_prompt_class
+        end
+      end
+    end
+  end
+end
diff --git a/ee/lib/gitlab/llm/open_ai/templates/generate_test_file.rb b/ee/lib/gitlab/llm/open_ai/templates/generate_test_file.rb
new file mode 100644
index 0000000000000000000000000000000000000000..717c7d076708e888f183c8caf7b1f7543816f274
--- /dev/null
+++ b/ee/lib/gitlab/llm/open_ai/templates/generate_test_file.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Llm
+    module OpenAi
+      module Templates
+        class GenerateTestFile
+          def self.get_options(merge_request, path)
+            prompt = <<-TEMPLATE
+            Write unit tests for #{path} to ensure its proper functioning but only if the file contains code
+            """
+            #{Gitlab::Llm::OpenAi::Templates::GenerateTestFile.get_diff_file_content(merge_request, path)}
+            """
+            TEMPLATE
+
+            {
+              content: prompt,
+              temperature: 0.2
+            }
+          end
+
+          def self.get_diff_file_content(merge_request, path)
+            file = merge_request.diffs.diff_files.find { |file| file.paths.include?(path) }
+
+            file&.blob&.data
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/ee/spec/frontend/ai/components/generate_test_file_drawer_spec.js b/ee/spec/frontend/ai/components/generate_test_file_drawer_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..a221b55172bd9ef47403a65dad4d53f161b93d91
--- /dev/null
+++ b/ee/spec/frontend/ai/components/generate_test_file_drawer_spec.js
@@ -0,0 +1,112 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlAlert } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import GenerateTestFileDrawer from 'ee/ai/components/generate_test_file_drawer.vue';
+import aiResponseSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response.subscription.graphql';
+import testFileGeneratorMutation from 'ee/ai/graphql/test_file_generator.mutation.graphql';
+
+Vue.use(VueApollo);
+Vue.config.ignoredElements = ['copy-code'];
+
+let wrapper;
+let subscriptionHandlerMock;
+let mutationHandlerMock;
+
+function createComponent() {
+  const apolloProvider = createMockApollo([
+    [aiResponseSubscription, subscriptionHandlerMock],
+    [testFileGeneratorMutation, mutationHandlerMock],
+  ]);
+
+  wrapper = mountExtended(GenerateTestFileDrawer, {
+    propsData: {
+      resourceId: 'gid://gitlab/MergeRequest/1',
+      filePath: 'index.js',
+    },
+    apolloProvider,
+  });
+}
+
+describe('Generate test file drawer component', () => {
+  beforeEach(() => {
+    window.gon.current_user_id = 1;
+    mutationHandlerMock = jest
+      .fn()
+      .mockResolvedValue({ data: { aiAction: { errors: [], __typename: 'AiActionPayload' } } });
+    subscriptionHandlerMock = jest.fn().mockResolvedValue({
+      data: {
+        aiCompletionResponse: {
+          responseBody: '<pre><code>This is test code</code></pre>',
+          errors: [],
+        },
+      },
+    });
+  });
+
+  afterEach(() => {
+    mutationHandlerMock.mockRestore();
+    subscriptionHandlerMock.mockRestore();
+  });
+
+  it('calls mutation when mounted', () => {
+    createComponent();
+
+    expect(mutationHandlerMock).toHaveBeenCalledWith({
+      filePath: 'index.js',
+      resourceId: 'gid://gitlab/MergeRequest/1',
+    });
+  });
+
+  it('calls subscription', () => {
+    createComponent();
+
+    expect(subscriptionHandlerMock).toHaveBeenCalledWith({
+      resourceId: 'gid://gitlab/MergeRequest/1',
+      userId: 'gid://gitlab/User/1',
+    });
+  });
+
+  it('shows loading state when subscription is loading', () => {
+    createComponent();
+
+    expect(wrapper.findByTestId('generate-test-loading-state').exists()).toBe(true);
+  });
+
+  it('renders returned test from subscription', async () => {
+    createComponent();
+
+    await waitForPromises();
+
+    expect(wrapper.findByTestId('generate-test-code').text()).toContain('This is test code');
+  });
+
+  it('emits close event when closed', () => {
+    createComponent();
+
+    wrapper.find('.gl-drawer-close-button').vm.$emit('click');
+
+    expect(wrapper.emitted().close).toBeDefined();
+  });
+
+  it('renders alert when test could not be generated', async () => {
+    subscriptionHandlerMock = jest.fn().mockResolvedValue({
+      data: {
+        aiCompletionResponse: {
+          responseBody: 'As the file does not contain any code',
+          errors: [],
+        },
+      },
+    });
+
+    createComponent();
+
+    await waitForPromises();
+
+    expect(wrapper.findComponent(GlAlert).text()).toContain(
+      'Unable to generate tests for specified file.',
+    );
+  });
+});
diff --git a/ee/spec/frontend/diffs/components/app_spec.js b/ee/spec/frontend/diffs/components/app_spec.js
index 5da8ebaef539955297c6b1616e5e0a083fc3cfe8..c179446aa283b4f7c25f5979bb2a17a63ecb9be5 100644
--- a/ee/spec/frontend/diffs/components/app_spec.js
+++ b/ee/spec/frontend/diffs/components/app_spec.js
@@ -14,6 +14,8 @@ const TEST_ENDPOINT = `${TEST_HOST}/diff/endpoint`;
 
 Vue.use(Vuex);
 
+Vue.config.ignoredElements = ['copy-code'];
+
 describe('diffs/components/app', () => {
   let store;
   let wrapper;
diff --git a/ee/spec/helpers/merge_requests_helper_spec.rb b/ee/spec/helpers/merge_requests_helper_spec.rb
index 0ceded51364216e7b701a13a1af756bbb84a9966..fbbc88093656b47bcf9cc9108998aef1570d21a9 100644
--- a/ee/spec/helpers/merge_requests_helper_spec.rb
+++ b/ee/spec/helpers/merge_requests_helper_spec.rb
@@ -16,4 +16,17 @@
       expect(render_items_list(%w(user user1 user2))).to eq("user, user1 and user2")
     end
   end
+
+  describe '#diffs_tab_pane_data' do
+    it 'returns data' do
+      project = build_stubbed(:project)
+      merge_request = build_stubbed(:merge_request, project: project)
+
+      allow(helper).to receive(:current_user).and_return(build_stubbed(:user))
+
+      expect(helper.diffs_tab_pane_data(project, merge_request, {})).to include({
+        show_generate_test_file_button: 'false'
+      })
+    end
+  end
 end
diff --git a/ee/spec/lib/gitlab/llm/open_ai/completions/generate_test_file_spec.rb b/ee/spec/lib/gitlab/llm/open_ai/completions/generate_test_file_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b11dd26105c778875fd26192448e15fc71ecd353
--- /dev/null
+++ b/ee/spec/lib/gitlab/llm/open_ai/completions/generate_test_file_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Llm::OpenAi::Completions::GenerateTestFile, feature_category: :code_review_workflow do
+  let_it_be(:user) { create(:user) }
+  let_it_be(:project) { create(:project, :public) }
+  let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+
+  let(:template_class) { ::Gitlab::Llm::OpenAi::Templates::GenerateTestFile }
+  let(:ai_template) { { content: 'something', temperature: 0.2 } }
+  let(:content) { "some ai response text" }
+  let(:ai_response) do
+    {
+      choices: [
+        {
+          message: {
+            content: content
+          }
+        }
+      ]
+    }.to_json
+  end
+
+  subject(:generate_test_file) do
+    described_class.new(template_class).execute(user, merge_request, { file_path: 'index.js' })
+  end
+
+  describe "#execute" do
+    context 'with invalid params' do
+      context 'without user' do
+        let(:user) { nil }
+
+        specify { expect(generate_test_file).to be_nil }
+      end
+
+      context 'without merge request' do
+        let_it_be(:merge_request) { nil }
+
+        specify { expect(generate_test_file).to be_nil }
+      end
+    end
+
+    context 'with valid params' do
+      it 'performs the OpenAI request' do
+        expect_next_instance_of(::Gitlab::Llm::OpenAi::Completions::GenerateTestFile) do |completion_service|
+          expect(completion_service).to receive(:execute).with(user, merge_request, { file_path: 'index.js' })
+            .and_call_original
+        end
+
+        expect(Gitlab::Llm::OpenAi::Templates::GenerateTestFile).to receive(:get_options).and_return(ai_template)
+
+        allow_next_instance_of(Gitlab::Llm::OpenAi::Client) do |instance|
+          params = { content: 'something', max_tokens: 1000, temperature: 0.2 }
+          allow(instance).to receive(:chat).with(params).and_return(ai_response)
+        end
+
+        uuid = 'uuid'
+
+        expect(SecureRandom).to receive(:uuid).and_return(uuid)
+
+        data = {
+          id: uuid,
+          model_name: 'MergeRequest',
+          response_body: content,
+          errors: []
+        }
+
+        expect(GraphqlTriggers).to receive(:ai_completion_response).with(
+          user.to_global_id, merge_request.to_global_id, data
+        )
+
+        generate_test_file
+      end
+    end
+  end
+end
diff --git a/ee/spec/lib/gitlab/llm/open_ai/templates/generate_test_file_spec.rb b/ee/spec/lib/gitlab/llm/open_ai/templates/generate_test_file_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..83d1a3c794425f27b075cc1d180fcba23b3208c5
--- /dev/null
+++ b/ee/spec/lib/gitlab/llm/open_ai/templates/generate_test_file_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Llm::OpenAi::Templates::GenerateTestFile, feature_category: :code_review_workflow do
+  let_it_be(:merge_request) { create(:merge_request) }
+
+  let(:path) { "files/js/commit.coffee" }
+
+  subject { described_class.get_options(merge_request, path) }
+
+  describe '.get_options' do
+    it 'returns correct parameters' do
+      expect(subject[:content]).to include("class Commit")
+      expect(subject[:content]).to include("Write unit tests for #{path} to ensure its proper functioning")
+      expect(subject[:content]).to include("but only if the file contains code")
+      expect(subject[:temperature]).to eq(0.2)
+    end
+  end
+end
diff --git a/ee/spec/requests/api/graphql/mutations/projects/generate_test_file_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/generate_test_file_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3ed698de156659c57a7ae9c60d12a0f36281d16b
--- /dev/null
+++ b/ee/spec/requests/api/graphql/mutations/projects/generate_test_file_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe 'AiAction for Generate Test File', feature_category: :code_review_workflow do
+  include GraphqlHelpers
+  include Graphql::Subscriptions::Notes::Helper
+
+  let_it_be(:project) { create(:project, :public) }
+  let_it_be(:current_user) { create(:user, developer_projects: [project]) }
+  let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+  let_it_be(:file_path) { "files/js/commit.coffee" }
+
+  let(:mutation) do
+    params = { generate_test_file: { file_path: file_path, resource_id: merge_request.to_gid } }
+
+    graphql_mutation(:ai_action, params) do
+      <<-QL.strip_heredoc
+        errors
+      QL
+    end
+  end
+
+  before do
+    stub_licensed_features(generate_test_file: true)
+  end
+
+  it 'successfully performs an explain code request' do
+    expect(Llm::CompletionWorker).to receive(:perform_async).with(
+      current_user.id, merge_request.id, "MergeRequest", :generate_test_file, {
+        file_path: file_path, markup_format: :raw
+      }
+    )
+
+    post_graphql_mutation(mutation, current_user: current_user)
+
+    expect(graphql_mutation_response(:ai_action)['errors']).to eq([])
+  end
+
+  context 'when empty messages are passed' do
+    let(:file_path) { "" }
+
+    it 'returns nil' do
+      expect(Llm::CompletionWorker).not_to receive(:perform_async)
+
+      post_graphql_mutation(mutation, current_user: current_user)
+
+      expect(fresh_response_data['errors'][0]['message']).to eq("filePath can't be blank")
+    end
+  end
+
+  context 'when openai_experimentation feature flag is disabled' do
+    before do
+      stub_feature_flags(openai_experimentation: false)
+    end
+
+    it 'returns nil' do
+      expect(Llm::CompletionWorker).not_to receive(:perform_async)
+
+      post_graphql_mutation(mutation, current_user: current_user)
+
+      expect(fresh_response_data['errors'][0]['message']).to eq("`openai_experimentation` feature flag is disabled.")
+    end
+  end
+end
diff --git a/ee/spec/services/llm/generate_test_file_service_spec.rb b/ee/spec/services/llm/generate_test_file_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..586ca57dfdca5c7fa3d81071baad5a40ec051977
--- /dev/null
+++ b/ee/spec/services/llm/generate_test_file_service_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Llm::GenerateTestFileService, feature_category: :code_review_workflow do
+  let_it_be(:user) { create(:user) }
+  let_it_be(:project) { create(:project, :public) }
+  let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+  let(:options) { {} }
+
+  subject { described_class.new(user, merge_request, options) }
+
+  describe '#execute' do
+    before do
+      stub_licensed_features(generate_test_file: true)
+      allow(Llm::CompletionWorker).to receive(:perform_async)
+    end
+
+    context 'when the user is permitted to view the merge request' do
+      before do
+        project.add_maintainer(user)
+      end
+
+      it 'schedules a job' do
+        expect(subject.execute).to be_success
+
+        expect(Llm::CompletionWorker).to have_received(:perform_async).with(
+          user.id,
+          merge_request.id,
+          'MergeRequest',
+          :generate_test_file,
+          options
+        )
+      end
+    end
+
+    context 'when the user is not permitted to view the merge request' do
+      it 'returns an error' do
+        project.team.truncate
+
+        expect(subject.execute).to be_error
+
+        expect(Llm::CompletionWorker).not_to have_received(:perform_async)
+      end
+    end
+
+    context 'when feature flag is disabled' do
+      before do
+        stub_feature_flags(generate_test_file: false)
+      end
+
+      it 'returns an error' do
+        expect(subject.execute).to be_error
+
+        expect(Llm::CompletionWorker).not_to have_received(:perform_async)
+      end
+    end
+  end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 4887fed6ca1771888e5615fcfe546b32ddd82c3b..bde74d91365d93eb7201ade233f578a64a85b6d7 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1833,6 +1833,9 @@ msgstr ""
 msgid "ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '%{domain}'"
 msgstr ""
 
+msgid "AI Generated Test File"
+msgstr ""
+
 msgid "AI actions"
 msgstr ""
 
@@ -18896,6 +18899,9 @@ msgstr ""
 msgid "Generate site and private keys at"
 msgstr ""
 
+msgid "Generate test with AI"
+msgstr ""
+
 msgid "Generated with JSON data"
 msgstr ""
 
@@ -47076,6 +47082,9 @@ msgstr ""
 msgid "Unable to generate new instance ID"
 msgstr ""
 
+msgid "Unable to generate tests for specified file."
+msgstr ""
+
 msgid "Unable to load commits. Try again later."
 msgstr ""
 
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index e58082a798f748e0946f4d5435a12ce0cf5c2313..aa74721ee3cb01e98ef9d33edf9a6b02e826b1ea 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -31,6 +31,8 @@ const UPDATED_COMMIT_URL = `${TEST_HOST}/COMMIT/NEW`;
 
 Vue.use(Vuex);
 
+Vue.config.ignoredElements = ['copy-code'];
+
 function getCollapsedFilesWarning(wrapper) {
   return wrapper.findComponent(CollapsedFilesWarning);
 }