Repository: getsentry/sentry
Pull request: feat(replays): Unit tests to Issues Replays List page
<!-- Describe your PR here. -->
### Changes
- Added unit tests for the `groupReplays.tsx`. 
- Added `fetchError` prop to the `ReplaysTable` component to catch api errors

Closes #38842.

### Tests coverage

- [x] Assert that query to the events endpoint is correct
- [x] Snapshot test for empty state
- [x] Assert that empty message when no replays are found is correct
- [x] Assert that the component displays an error message when API call fails
- [x] Assert that the component displays a loading indicator when fetching replays
- [x] Assert that the list of replays are being shown and have the correct values
- [x] Assert that sorting each column works
- [x] Assert that the component displays an alert message when the organization doesn't have replay features 

### Notes

While writing the tests I discovered that the pagination will not work due to a [bug](https://github.com/getsentry/sentry/issues/39464). When searching for replays ids it will not search for the total, it will be capped by the per page limit, for instance if we have a limit of 50 replays per page, it will fetch for a maximum of 50 replays ids, not more, this makes our pagination non-functional.

Once this is fixed we should make a follow up PR and add tests for the pagination.

<!--

  Sentry employees and contractors can delete or ignore the following.

-->

### Legal Boilerplate

Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms.


Problem statement:
[jest] Issues list page loads
- [ ] assert that query to the events endpoint is correct
- [ ] snapshot test for empty state


1. assert that get/fetch call has the correct search params
2. assert that we can change search filters (time, project, text) and get/fetch is correct each time
3. assert that invalid search params show a message
4. assert that sorting each column works
5. assert that pagination works, can click into next page, get/fetch is correct
6. assert that the href on each row is correct
7. assert list shows empty message
8. any more that you can think of

Hints from issue discussion:

Commit message: feat: add new feature check test

Patch to review:
diff --git a/static/app/views/organizationGroupDetails/groupReplays/groupReplays.spec.tsx b/static/app/views/organizationGroupDetails/groupReplays/groupReplays.spec.tsx
new file mode 100644
index 00000000000000..f8c2d50c173f0e
--- /dev/null
+++ b/static/app/views/organizationGroupDetails/groupReplays/groupReplays.spec.tsx
@@ -0,0 +1,460 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import ProjectsStore from 'sentry/stores/projectsStore';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+import GroupReplays from 'sentry/views/organizationGroupDetails/groupReplays';
+import {RouteContext} from 'sentry/views/routeContext';
+
+type InitializeOrgProps = {
+  location?: {
+    pathname?: string;
+    query?: {[key: string]: string};
+  };
+  organizationProps?: {
+    features?: string[];
+  };
+};
+
+const mockUrl = '/organizations/org-slug/replays/';
+
+const mockProps = {
+  group: TestStubs.Group(),
+  replayIds: ['346789a703f6454384f1de473b8b9fcc', 'b05dae9b6be54d21a4d5ad9f8f02b780'],
+};
+
+let mockRouterContext: {
+  childContextTypes?: any;
+  context?: any;
+} = {};
+
+const getComponent = ({
+  location,
+  organizationProps = {features: ['session-replay-ui']},
+}: InitializeOrgProps) => {
+  const {router, organization, routerContext} = initializeOrg({
+    organization: {
+      ...organizationProps,
+    },
+    project: TestStubs.Project(),
+    projects: [TestStubs.Project()],
+    router: {
+      location: {
+        pathname: '/organizations/org-slug/replays/',
+        query: {},
+        ...location,
+      },
+    },
+  });
+
+  ProjectsStore.init();
+  ProjectsStore.loadInitialData(organization.projects);
+
+  mockRouterContext = routerContext;
+
+  return (
+    <OrganizationContext.Provider value={organization}>
+      <RouteContext.Provider
+        value={{
+          router,
+          location: router.location,
+          params: router.params,
+          routes: [],
+        }}
+      >
+        <GroupReplays {...mockProps} />
+      </RouteContext.Provider>
+    </OrganizationContext.Provider>
+  );
+};
+
+const renderComponent = (componentProps: InitializeOrgProps = {}) => {
+  return render(getComponent(componentProps), {context: mockRouterContext});
+};
+
+describe('GroupReplays', () => {
+  beforeEach(() => {
+    MockApiClient.clearMockResponses();
+  });
+
+  // Assert that query to the events endpoint is correct
+  it('Should have correct queries in the events endpoint', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+      // Expect api path to have the correct query params
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            statsPeriod: '14d',
+            project: ['2'],
+            environment: [],
+            field: [
+              'countErrors',
+              'duration',
+              'finishedAt',
+              'id',
+              'projectId',
+              'startedAt',
+              'urls',
+              'user',
+            ],
+            sort: '-startedAt',
+            per_page: 50,
+            query:
+              'id:[346789a703f6454384f1de473b8b9fcc,b05dae9b6be54d21a4d5ad9f8f02b780]',
+          }),
+        })
+      );
+    });
+  });
+
+  it('Should snapshot empty state', async () => {
+    MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    const {container} = renderComponent();
+
+    await waitFor(() => {
+      expect(container).toSnapshot();
+    });
+  });
+
+  it('Should show empty message when no replays are found', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+      expect(screen.getByText('There are no items to display')).toBeInTheDocument();
+    });
+  });
+
+  it('Should display error message when api call fails', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      statusCode: 500,
+      body: {},
+    });
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+      expect(
+        screen.getByText(
+          'Sorry, the list of replays could not be loaded. This could be due to invalid search parameters or an internal systems error.'
+        )
+      ).toBeInTheDocument();
+    });
+  });
+
+  it('Should show loading indicator when loading replays', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      statusCode: 200,
+      body: {
+        data: [],
+      },
+    });
+
+    renderComponent();
+
+    expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  it('Should show a list of replays and have the correct values', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      statusCode: 200,
+      body: {
+        data: [
+          {
+            countErrors: 1,
+            duration: 52346,
+            finishedAt: '2022-09-15T06:54:00+00:00',
+            id: '346789a703f6454384f1de473b8b9fcc',
+            projectId: '2',
+            startedAt: '2022-09-15T06:50:03+00:00',
+            urls: [
+              'https://dev.getsentry.net:7999/organizations/sentry-emerging-tech/replays/',
+              '/organizations/sentry-emerging-tech/replays/?project=2',
+            ],
+            user: {
+              id: '147086',
+              name: '',
+              email: '',
+              ip_address: '127.0.0.1',
+              displayName: 'testDisplayName',
+            },
+          },
+          {
+            countErrors: 4,
+            duration: 400,
+            finishedAt: '2022-09-21T21:40:38+00:00',
+            id: 'b05dae9b6be54d21a4d5ad9f8f02b780',
+            projectId: '2',
+            startedAt: '2022-09-21T21:30:44+00:00',
+            urls: [
+              'https://dev.getsentry.net:7999/organizations/sentry-emerging-tech/replays/?project=2&statsPeriod=24h',
+              '/organizations/sentry-emerging-tech/issues/',
+              '/organizations/sentry-emerging-tech/issues/?project=2',
+            ],
+            user: {
+              id: '147086',
+              name: '',
+              email: '',
+              ip_address: '127.0.0.1',
+              displayName: 'testDisplayName',
+            },
+          },
+        ],
+      },
+    });
+
+    // Mock the system date to be 2022-09-28
+    jest.useFakeTimers().setSystemTime(new Date('Sep 28, 2022 11:29:13 PM UTC'));
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+    });
+
+    // Expect the table to have 2 rows
+    expect(screen.getAllByText('testDisplayName')).toHaveLength(2);
+
+    // Expect the first row to have the correct href
+    expect(screen.getAllByRole('link', {name: 'testDisplayName'})[0]).toHaveAttribute(
+      'href',
+      '/organizations/org-slug/replays/project-slug:346789a703f6454384f1de473b8b9fcc/?referrer='
+    );
+
+    // Expect the second row to have the correct href
+    expect(screen.getAllByRole('link', {name: 'testDisplayName'})[1]).toHaveAttribute(
+      'href',
+      '/organizations/org-slug/replays/project-slug:b05dae9b6be54d21a4d5ad9f8f02b780/?referrer='
+    );
+
+    // Expect the first row to have the correct duration
+    expect(screen.getByText('14hr 32min 26s')).toBeInTheDocument();
+
+    // Expect the second row to have the correct duration
+    expect(screen.getByText('6min 40s')).toBeInTheDocument();
+
+    // Expect the first row to have the correct errors
+    expect(screen.getByText('1')).toBeInTheDocument();
+
+    // Expect the second row to have the correct errors
+    expect(screen.getByText('4')).toBeInTheDocument();
+
+    // Expect the first row to have the correct date
+    expect(screen.getByText('14 days ago')).toBeInTheDocument();
+
+    // Expect the second row to have the correct date
+    expect(screen.getByText('7 days ago')).toBeInTheDocument();
+  });
+
+  it('Should sort by start time correctly', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    const {rerender} = renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            sort: '-startedAt',
+          }),
+        })
+      );
+    });
+
+    // Click on the start time header and expect the sort to be startedAt
+    userEvent.click(screen.getByRole('columnheader', {name: 'Start Time'}));
+
+    expect(mockRouterContext.context.router.push).toHaveBeenCalledWith({
+      pathname: '/organizations/org-slug/replays/',
+      query: {
+        sort: 'startedAt',
+      },
+    });
+
+    // Need to simulate a rerender to get the new sort
+    rerender(
+      getComponent({
+        location: {
+          query: {
+            sort: 'startedAt',
+          },
+        },
+      })
+    );
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(2);
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            sort: 'startedAt',
+          }),
+        })
+      );
+    });
+  });
+
+  it('Should sort by duration correctly', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    const {rerender} = renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            sort: '-startedAt',
+          }),
+        })
+      );
+    });
+
+    // Click on the duration header and expect the sort to be duration
+    userEvent.click(screen.getByRole('columnheader', {name: 'Duration'}));
+
+    expect(mockRouterContext.context.router.push).toHaveBeenCalledWith({
+      pathname: '/organizations/org-slug/replays/',
+      query: {
+        sort: 'duration',
+      },
+    });
+
+    // Need to simulate a rerender to get the new sort
+    rerender(
+      getComponent({
+        location: {
+          query: {
+            sort: 'duration',
+          },
+        },
+      })
+    );
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(2);
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            sort: 'duration',
+          }),
+        })
+      );
+    });
+  });
+
+  it('Should sort by errors correctly', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    const {rerender} = renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            sort: '-startedAt',
+          }),
+        })
+      );
+    });
+
+    // Click on the errors header and expect the sort to be countErrors
+    userEvent.click(screen.getByRole('columnheader', {name: 'Errors'}));
+
+    expect(mockRouterContext.context.router.push).toHaveBeenCalledWith({
+      pathname: '/organizations/org-slug/replays/',
+      query: {
+        sort: 'countErrors',
+      },
+    });
+
+    // Need to simulate a rerender to get the new sort
+    rerender(
+      getComponent({
+        location: {
+          query: {
+            sort: 'countErrors',
+          },
+        },
+      })
+    );
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(2);
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            sort: 'countErrors',
+          }),
+        })
+      );
+    });
+  });
+
+  it("Should access message when the organization doesn't have access to the replay feature", () => {
+    renderComponent({
+      organizationProps: {
+        features: [],
+      },
+    });
+
+    expect(screen.getByText("You don't have access to this feature")).toBeInTheDocument();
+  });
+});
diff --git a/static/app/views/organizationGroupDetails/groupReplays/groupReplays.tsx b/static/app/views/organizationGroupDetails/groupReplays/groupReplays.tsx
index 44a750c2f95d88..22c6ed75e08def 100644
--- a/static/app/views/organizationGroupDetails/groupReplays/groupReplays.tsx
+++ b/static/app/views/organizationGroupDetails/groupReplays/groupReplays.tsx
@@ -38,7 +38,7 @@ const GroupReplays = ({group, replayIds}: Props) => {
     );
   }, [location, project.id, replayIds]);
 
-  const {replays, pageLinks, isFetching} = useReplayList({
+  const {replays, pageLinks, isFetching, fetchError} = useReplayList({
     organization,
     eventView,
   });
@@ -50,6 +50,7 @@ const GroupReplays = ({group, replayIds}: Props) => {
         replays={replays}
         showProjectColumn={false}
         sort={eventView.sorts[0]}
+        fetchError={fetchError}
       />
       <Pagination pageLinks={pageLinks} />
     </StyledPageContent>


Code review comments:
diff_hunk: @@ -0,0 +1,460 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import ProjectsStore from 'sentry/stores/projectsStore';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+import GroupReplays from 'sentry/views/organizationGroupDetails/groupReplays';
+import {RouteContext} from 'sentry/views/routeContext';
+
+type InitializeOrgProps = {
+  location?: {
+    pathname?: string;
+    query?: {[key: string]: string};
+  };
+  organizationProps?: {
+    features?: string[];
+  };
+};
+
+const mockUrl = '/organizations/org-slug/replays/';
+
+const mockProps = {
+  group: TestStubs.Group(),
+  replayIds: ['346789a703f6454384f1de473b8b9fcc', 'b05dae9b6be54d21a4d5ad9f8f02b780'],
+};
+
+let mockRouterContext: {
+  childContextTypes?: any;
+  context?: any;
+} = {};
+
+const getComponent = ({
+  location,
+  organizationProps = {features: ['session-replay-ui']},
+}: InitializeOrgProps) => {
+  const {router, organization, routerContext} = initializeOrg({
+    organization: {
+      ...organizationProps,
+    },
+    project: TestStubs.Project(),
+    projects: [TestStubs.Project()],
+    router: {
+      location: {
+        pathname: '/organizations/org-slug/replays/',
+        query: {},
+        ...location,
+      },
+    },
+  });
+
+  ProjectsStore.init();
+  ProjectsStore.loadInitialData(organization.projects);
+
+  mockRouterContext = routerContext;
+
+  return (
+    <OrganizationContext.Provider value={organization}>
+      <RouteContext.Provider
+        value={{
+          router,
+          location: router.location,
+          params: router.params,
+          routes: [],
+        }}
+      >
+        <GroupReplays {...mockProps} />
+      </RouteContext.Provider>
+    </OrganizationContext.Provider>
+  );
+};
+
+const renderComponent = (componentProps: InitializeOrgProps = {}) => {
+  return render(getComponent(componentProps), {context: mockRouterContext});
+};
+
+describe('GroupReplays', () => {
+  beforeEach(() => {
+    MockApiClient.clearMockResponses();
+  });
+
+  // Assert that query to the events endpoint is correct
+  it('Should have correct queries in the events endpoint', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+      // Expect api path to have the correct query params
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            statsPeriod: '14d',
+            project: ['2'],
+            environment: [],
+            field: [
+              'countErrors',
+              'duration',
+              'finishedAt',
+              'id',
+              'projectId',
+              'startedAt',
+              'urls',
+              'user',
+            ],
+            sort: '-startedAt',
+            per_page: 50,
+            query:
+              'id:[346789a703f6454384f1de473b8b9fcc,b05dae9b6be54d21a4d5ad9f8f02b780]',
+          }),
+        })
+      );
+    });
+  });
+
+  it('Should snapshot empty state', async () => {
+    MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    const {container} = renderComponent();
+
+    await waitFor(() => {
+      expect(container).toSnapshot();
+    });
+  });
+
+  it('Should show empty message when no replays are found', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+      expect(screen.getByText('There are no items to display')).toBeInTheDocument();
+    });
+  });
+
+  it('Should display error message when api call fails', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      statusCode: 500,
+      body: {},
+    });
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+      expect(
+        screen.getByText(
+          'Sorry, the list of replays could not be loaded. This could be due to invalid search parameters or an internal systems error.'
+        )
+      ).toBeInTheDocument();
+    });
+  });
+
+  it('Should show loading indicator when loading replays', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      statusCode: 200,
+      body: {
+        data: [],
+      },
+    });
+
+    renderComponent();
+
+    expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  it('Should show a list of replays and have the correct values', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      statusCode: 200,
+      body: {
+        data: [
+          {
+            countErrors: 1,
+            duration: 52346,
+            finishedAt: '2022-09-15T06:54:00+00:00',
+            id: '346789a703f6454384f1de473b8b9fcc',
+            projectId: '2',
+            startedAt: '2022-09-15T06:50:03+00:00',
+            urls: [
+              'https://dev.getsentry.net:7999/organizations/sentry-emerging-tech/replays/',
+              '/organizations/sentry-emerging-tech/replays/?project=2',
+            ],
+            user: {
+              id: '147086',
+              name: '',
+              email: '',
+              ip_address: '127.0.0.1',
+              displayName: 'testDisplayName',
+            },
+          },
+          {
+            countErrors: 4,
+            duration: 400,
+            finishedAt: '2022-09-21T21:40:38+00:00',
+            id: 'b05dae9b6be54d21a4d5ad9f8f02b780',
+            projectId: '2',
+            startedAt: '2022-09-21T21:30:44+00:00',
+            urls: [
+              'https://dev.getsentry.net:7999/organizations/sentry-emerging-tech/replays/?project=2&statsPeriod=24h',
+              '/organizations/sentry-emerging-tech/issues/',
+              '/organizations/sentry-emerging-tech/issues/?project=2',
+            ],
+            user: {
+              id: '147086',
+              name: '',
+              email: '',
+              ip_address: '127.0.0.1',
+              displayName: 'testDisplayName',
+            },
+          },
+        ],
+      },
+    });
+
+    // Mock the system date to be 2022-09-28
+    jest.useFakeTimers().setSystemTime(new Date('Sep 28, 2022 11:29:13 PM UTC'));
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+    });
+
+    // Expect the table to have 2 rows
+    expect(screen.getAllByText('testDisplayName')).toHaveLength(2);
+
+    // Expect the first row to have the correct href
+    expect(screen.getAllByRole('link', {name: 'testDisplayName'})[0]).toHaveAttribute(
+      'href',
+      '/organizations/org-slug/replays/project-slug:346789a703f6454384f1de473b8b9fcc/?referrer='
+    );
+
+    // Expect the second row to have the correct href
+    expect(screen.getAllByRole('link', {name: 'testDisplayName'})[1]).toHaveAttribute(
+      'href',
+      '/organizations/org-slug/replays/project-slug:b05dae9b6be54d21a4d5ad9f8f02b780/?referrer='
+    );
+
+    // Expect the first row to have the correct duration
+    expect(screen.getByText('14hr 32min 26s')).toBeInTheDocument();
+
+    // Expect the second row to have the correct duration
+    expect(screen.getByText('6min 40s')).toBeInTheDocument();
+
+    // Expect the first row to have the correct errors
+    expect(screen.getByText('1')).toBeInTheDocument();
+
+    // Expect the second row to have the correct errors
+    expect(screen.getByText('4')).toBeInTheDocument();
+
+    // Expect the first row to have the correct date
+    expect(screen.getByText('14 days ago')).toBeInTheDocument();
+
+    // Expect the second row to have the correct date
+    expect(screen.getByText('7 days ago')).toBeInTheDocument();
+  });
+
+  it('Should sort by start time correctly', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    const {rerender} = renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            sort: '-startedAt',
+          }),
+        })
+      );
+    });
+
+    // Click on the start time header and expect the sort to be startedAt
+    userEvent.click(screen.getByRole('columnheader', {name: 'Start Time'}));
+
+    expect(mockRouterContext.context.router.push).toHaveBeenCalledWith({
+      pathname: '/organizations/org-slug/replays/',
+      query: {
+        sort: 'startedAt',
+      },
+    });
+
+    // Need to simulate a rerender to get the new sort
+    rerender(
+      getComponent({
+        location: {
+          query: {
+            sort: 'startedAt',
+          },
+        },
+      })
+    );
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(2);
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            sort: 'startedAt',
+          }),
+        })
+      );
+    });
+  });
+
+  it('Should sort by duration correctly', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    const {rerender} = renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            sort: '-startedAt',
+          }),
+        })
+      );
+    });
+
+    // Click on the duration header and expect the sort to be duration
+    userEvent.click(screen.getByRole('columnheader', {name: 'Duration'}));
+
+    expect(mockRouterContext.context.router.push).toHaveBeenCalledWith({
+      pathname: '/organizations/org-slug/replays/',
+      query: {
+        sort: 'duration',
+      },
+    });
+
+    // Need to simulate a rerender to get the new sort
+    rerender(
+      getComponent({
+        location: {
+          query: {
+            sort: 'duration',
+          },
+        },
+      })
+    );
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(2);
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            sort: 'duration',
+          }),
+        })
+      );
+    });
+  });
+
+  it('Should sort by errors correctly', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    const {rerender} = renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            sort: '-startedAt',
+          }),
+        })
+      );
+    });
+
+    // Click on the errors header and expect the sort to be countErrors
+    userEvent.click(screen.getByRole('columnheader', {name: 'Errors'}));
+
+    expect(mockRouterContext.context.router.push).toHaveBeenCalledWith({
+      pathname: '/organizations/org-slug/replays/',
+      query: {
+        sort: 'countErrors',
+      },
+    });
+
+    // Need to simulate a rerender to get the new sort
+    rerender(
+      getComponent({
+        location: {
+          query: {
+            sort: 'countErrors',
+          },
+        },
+      })
+    );
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(2);
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            sort: 'countErrors',
+          }),
+        })
+      );
+    });
+  });
+
+  it("Should access message when the organization doesn't have access to the replay feature", () => {
path: static/app/views/organizationGroupDetails/groupReplays/groupReplays.spec.tsx
text: @user1:
```suggestion
  it("should show a message when the organization doesn't have access to the replay feature", () => {
```

diff_hunk: @@ -0,0 +1,460 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import ProjectsStore from 'sentry/stores/projectsStore';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+import GroupReplays from 'sentry/views/organizationGroupDetails/groupReplays';
+import {RouteContext} from 'sentry/views/routeContext';
+
+type InitializeOrgProps = {
+  location?: {
+    pathname?: string;
+    query?: {[key: string]: string};
+  };
+  organizationProps?: {
+    features?: string[];
+  };
+};
+
+const mockUrl = '/organizations/org-slug/replays/';
+
+const mockProps = {
+  group: TestStubs.Group(),
+  replayIds: ['346789a703f6454384f1de473b8b9fcc', 'b05dae9b6be54d21a4d5ad9f8f02b780'],
+};
+
+let mockRouterContext: {
+  childContextTypes?: any;
+  context?: any;
+} = {};
+
+const getComponent = ({
+  location,
+  organizationProps = {features: ['session-replay-ui']},
+}: InitializeOrgProps) => {
+  const {router, organization, routerContext} = initializeOrg({
+    organization: {
+      ...organizationProps,
+    },
+    project: TestStubs.Project(),
+    projects: [TestStubs.Project()],
+    router: {
+      location: {
+        pathname: '/organizations/org-slug/replays/',
+        query: {},
+        ...location,
+      },
+    },
+  });
+
+  ProjectsStore.init();
+  ProjectsStore.loadInitialData(organization.projects);
+
+  mockRouterContext = routerContext;
+
+  return (
+    <OrganizationContext.Provider value={organization}>
+      <RouteContext.Provider
+        value={{
+          router,
+          location: router.location,
+          params: router.params,
+          routes: [],
+        }}
+      >
+        <GroupReplays {...mockProps} />
+      </RouteContext.Provider>
+    </OrganizationContext.Provider>
+  );
+};
+
+const renderComponent = (componentProps: InitializeOrgProps = {}) => {
+  return render(getComponent(componentProps), {context: mockRouterContext});
+};
+
+describe('GroupReplays', () => {
+  beforeEach(() => {
+    MockApiClient.clearMockResponses();
+  });
+
+  // Assert that query to the events endpoint is correct
+  it('Should have correct queries in the events endpoint', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+      // Expect api path to have the correct query params
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            statsPeriod: '14d',
+            project: ['2'],
+            environment: [],
+            field: [
+              'countErrors',
+              'duration',
+              'finishedAt',
+              'id',
+              'projectId',
+              'startedAt',
+              'urls',
+              'user',
+            ],
+            sort: '-startedAt',
+            per_page: 50,
+            query:
+              'id:[346789a703f6454384f1de473b8b9fcc,b05dae9b6be54d21a4d5ad9f8f02b780]',
+          }),
+        })
+      );
+    });
+  });
+
+  it('Should snapshot empty state', async () => {
+    MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    const {container} = renderComponent();
+
+    await waitFor(() => {
+      expect(container).toSnapshot();
+    });
+  });
+
+  it('Should show empty message when no replays are found', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+      expect(screen.getByText('There are no items to display')).toBeInTheDocument();
+    });
+  });
+
+  it('Should display error message when api call fails', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      statusCode: 500,
+      body: {},
+    });
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+      expect(
+        screen.getByText(
+          'Sorry, the list of replays could not be loaded. This could be due to invalid search parameters or an internal systems error.'
+        )
+      ).toBeInTheDocument();
+    });
+  });
+
+  it('Should show loading indicator when loading replays', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      statusCode: 200,
+      body: {
+        data: [],
+      },
+    });
+
+    renderComponent();
+
+    expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  it('Should show a list of replays and have the correct values', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      statusCode: 200,
+      body: {
+        data: [
+          {
+            countErrors: 1,
+            duration: 52346,
+            finishedAt: '2022-09-15T06:54:00+00:00',
+            id: '346789a703f6454384f1de473b8b9fcc',
+            projectId: '2',
+            startedAt: '2022-09-15T06:50:03+00:00',
+            urls: [
+              'https://dev.getsentry.net:7999/organizations/sentry-emerging-tech/replays/',
+              '/organizations/sentry-emerging-tech/replays/?project=2',
+            ],
+            user: {
+              id: '147086',
+              name: '',
+              email: '',
+              ip_address: '127.0.0.1',
+              displayName: 'testDisplayName',
+            },
+          },
+          {
+            countErrors: 4,
+            duration: 400,
+            finishedAt: '2022-09-21T21:40:38+00:00',
+            id: 'b05dae9b6be54d21a4d5ad9f8f02b780',
+            projectId: '2',
+            startedAt: '2022-09-21T21:30:44+00:00',
+            urls: [
+              'https://dev.getsentry.net:7999/organizations/sentry-emerging-tech/replays/?project=2&statsPeriod=24h',
+              '/organizations/sentry-emerging-tech/issues/',
+              '/organizations/sentry-emerging-tech/issues/?project=2',
+            ],
+            user: {
+              id: '147086',
+              name: '',
+              email: '',
+              ip_address: '127.0.0.1',
+              displayName: 'testDisplayName',
+            },
+          },
+        ],
+      },
+    });
+
+    // Mock the system date to be 2022-09-28
+    jest.useFakeTimers().setSystemTime(new Date('Sep 28, 2022 11:29:13 PM UTC'));
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+    });
+
+    // Expect the table to have 2 rows
+    expect(screen.getAllByText('testDisplayName')).toHaveLength(2);
+
+    // Expect the first row to have the correct href
+    expect(screen.getAllByRole('link', {name: 'testDisplayName'})[0]).toHaveAttribute(
+      'href',
+      '/organizations/org-slug/replays/project-slug:346789a703f6454384f1de473b8b9fcc/?referrer='
path: static/app/views/organizationGroupDetails/groupReplays/groupReplays.spec.tsx
text: @user1:
shouldn't `referrer=` have a value, is this something that needs to be mocked to work properly in the test?

diff_hunk: @@ -0,0 +1,460 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import ProjectsStore from 'sentry/stores/projectsStore';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+import GroupReplays from 'sentry/views/organizationGroupDetails/groupReplays';
+import {RouteContext} from 'sentry/views/routeContext';
+
+type InitializeOrgProps = {
+  location?: {
+    pathname?: string;
+    query?: {[key: string]: string};
+  };
+  organizationProps?: {
+    features?: string[];
+  };
+};
+
+const mockUrl = '/organizations/org-slug/replays/';
+
+const mockProps = {
+  group: TestStubs.Group(),
+  replayIds: ['346789a703f6454384f1de473b8b9fcc', 'b05dae9b6be54d21a4d5ad9f8f02b780'],
+};
+
+let mockRouterContext: {
+  childContextTypes?: any;
+  context?: any;
+} = {};
+
+const getComponent = ({
+  location,
+  organizationProps = {features: ['session-replay-ui']},
+}: InitializeOrgProps) => {
+  const {router, organization, routerContext} = initializeOrg({
+    organization: {
+      ...organizationProps,
+    },
+    project: TestStubs.Project(),
+    projects: [TestStubs.Project()],
+    router: {
+      location: {
+        pathname: '/organizations/org-slug/replays/',
+        query: {},
+        ...location,
+      },
+    },
+  });
+
+  ProjectsStore.init();
+  ProjectsStore.loadInitialData(organization.projects);
+
+  mockRouterContext = routerContext;
+
+  return (
+    <OrganizationContext.Provider value={organization}>
+      <RouteContext.Provider
+        value={{
+          router,
+          location: router.location,
+          params: router.params,
+          routes: [],
+        }}
+      >
+        <GroupReplays {...mockProps} />
+      </RouteContext.Provider>
+    </OrganizationContext.Provider>
+  );
+};
+
+const renderComponent = (componentProps: InitializeOrgProps = {}) => {
+  return render(getComponent(componentProps), {context: mockRouterContext});
+};
+
+describe('GroupReplays', () => {
+  beforeEach(() => {
+    MockApiClient.clearMockResponses();
+  });
+
+  // Assert that query to the events endpoint is correct
+  it('Should have correct queries in the events endpoint', async () => {
path: static/app/views/organizationGroupDetails/groupReplays/groupReplays.spec.tsx
text: @user1:
seems like the comment is trying to help describe the test. Instead of using a comment, can we improve the name of the test?

```suggestion
  it('should query the events endpoint with the passed in replayIds', async () => {
```

something like that imo

diff_hunk: @@ -0,0 +1,460 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import ProjectsStore from 'sentry/stores/projectsStore';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+import GroupReplays from 'sentry/views/organizationGroupDetails/groupReplays';
+import {RouteContext} from 'sentry/views/routeContext';
+
+type InitializeOrgProps = {
+  location?: {
+    pathname?: string;
+    query?: {[key: string]: string};
+  };
+  organizationProps?: {
+    features?: string[];
+  };
+};
+
+const mockUrl = '/organizations/org-slug/replays/';
+
+const mockProps = {
+  group: TestStubs.Group(),
+  replayIds: ['346789a703f6454384f1de473b8b9fcc', 'b05dae9b6be54d21a4d5ad9f8f02b780'],
+};
+
+let mockRouterContext: {
+  childContextTypes?: any;
+  context?: any;
+} = {};
+
+const getComponent = ({
+  location,
+  organizationProps = {features: ['session-replay-ui']},
+}: InitializeOrgProps) => {
+  const {router, organization, routerContext} = initializeOrg({
+    organization: {
+      ...organizationProps,
+    },
+    project: TestStubs.Project(),
+    projects: [TestStubs.Project()],
+    router: {
+      location: {
+        pathname: '/organizations/org-slug/replays/',
+        query: {},
+        ...location,
+      },
+    },
+  });
+
+  ProjectsStore.init();
+  ProjectsStore.loadInitialData(organization.projects);
+
+  mockRouterContext = routerContext;
+
+  return (
+    <OrganizationContext.Provider value={organization}>
+      <RouteContext.Provider
+        value={{
+          router,
+          location: router.location,
+          params: router.params,
+          routes: [],
+        }}
+      >
+        <GroupReplays {...mockProps} />
+      </RouteContext.Provider>
+    </OrganizationContext.Provider>
+  );
+};
+
+const renderComponent = (componentProps: InitializeOrgProps = {}) => {
+  return render(getComponent(componentProps), {context: mockRouterContext});
+};
+
+describe('GroupReplays', () => {
+  beforeEach(() => {
+    MockApiClient.clearMockResponses();
+  });
+
+  // Assert that query to the events endpoint is correct
+  it('Should have correct queries in the events endpoint', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+      // Expect api path to have the correct query params
+      expect(mockApi).toHaveBeenCalledWith(
+        mockUrl,
+        expect.objectContaining({
+          query: expect.objectContaining({
+            statsPeriod: '14d',
+            project: ['2'],
+            environment: [],
+            field: [
+              'countErrors',
+              'duration',
+              'finishedAt',
+              'id',
+              'projectId',
+              'startedAt',
+              'urls',
+              'user',
+            ],
+            sort: '-startedAt',
+            per_page: 50,
+            query:
+              'id:[346789a703f6454384f1de473b8b9fcc,b05dae9b6be54d21a4d5ad9f8f02b780]',
+          }),
+        })
+      );
+    });
+  });
+
+  it('Should snapshot empty state', async () => {
+    MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    const {container} = renderComponent();
+
+    await waitFor(() => {
+      expect(container).toSnapshot();
+    });
+  });
+
+  it('Should show empty message when no replays are found', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      body: {
+        data: [],
+      },
+      statusCode: 200,
+    });
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+      expect(screen.getByText('There are no items to display')).toBeInTheDocument();
+    });
+  });
+
+  it('Should display error message when api call fails', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      statusCode: 500,
+      body: {},
+    });
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+      expect(
+        screen.getByText(
+          'Sorry, the list of replays could not be loaded. This could be due to invalid search parameters or an internal systems error.'
+        )
+      ).toBeInTheDocument();
+    });
+  });
+
+  it('Should show loading indicator when loading replays', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      statusCode: 200,
+      body: {
+        data: [],
+      },
+    });
+
+    renderComponent();
+
+    expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  it('Should show a list of replays and have the correct values', async () => {
+    const mockApi = MockApiClient.addMockResponse({
+      url: mockUrl,
+      statusCode: 200,
+      body: {
+        data: [
+          {
+            countErrors: 1,
+            duration: 52346,
+            finishedAt: '2022-09-15T06:54:00+00:00',
+            id: '346789a703f6454384f1de473b8b9fcc',
+            projectId: '2',
+            startedAt: '2022-09-15T06:50:03+00:00',
+            urls: [
+              'https://dev.getsentry.net:7999/organizations/sentry-emerging-tech/replays/',
+              '/organizations/sentry-emerging-tech/replays/?project=2',
+            ],
+            user: {
+              id: '147086',
+              name: '',
+              email: '',
+              ip_address: '127.0.0.1',
+              displayName: 'testDisplayName',
+            },
+          },
+          {
+            countErrors: 4,
+            duration: 400,
+            finishedAt: '2022-09-21T21:40:38+00:00',
+            id: 'b05dae9b6be54d21a4d5ad9f8f02b780',
+            projectId: '2',
+            startedAt: '2022-09-21T21:30:44+00:00',
+            urls: [
+              'https://dev.getsentry.net:7999/organizations/sentry-emerging-tech/replays/?project=2&statsPeriod=24h',
+              '/organizations/sentry-emerging-tech/issues/',
+              '/organizations/sentry-emerging-tech/issues/?project=2',
+            ],
+            user: {
+              id: '147086',
+              name: '',
+              email: '',
+              ip_address: '127.0.0.1',
+              displayName: 'testDisplayName',
+            },
+          },
+        ],
+      },
+    });
+
+    // Mock the system date to be 2022-09-28
+    jest.useFakeTimers().setSystemTime(new Date('Sep 28, 2022 11:29:13 PM UTC'));
+
+    renderComponent();
+
+    await waitFor(() => {
+      expect(mockApi).toHaveBeenCalledTimes(1);
+    });
+
+    // Expect the table to have 2 rows
+    expect(screen.getAllByText('testDisplayName')).toHaveLength(2);
+
+    // Expect the first row to have the correct href
+    expect(screen.getAllByRole('link', {name: 'testDisplayName'})[0]).toHaveAttribute(
+      'href',
+      '/organizations/org-slug/replays/project-slug:346789a703f6454384f1de473b8b9fcc/?referrer='
+    );
+
+    // Expect the second row to have the correct href
+    expect(screen.getAllByRole('link', {name: 'testDisplayName'})[1]).toHaveAttribute(
+      'href',
+      '/organizations/org-slug/replays/project-slug:b05dae9b6be54d21a4d5ad9f8f02b780/?referrer='
+    );
+
+    // Expect the first row to have the correct duration
+    expect(screen.getByText('14hr 32min 26s')).toBeInTheDocument();
+
+    // Expect the second row to have the correct duration
+    expect(screen.getByText('6min 40s')).toBeInTheDocument();
+
+    // Expect the first row to have the correct errors
+    expect(screen.getByText('1')).toBeInTheDocument();
path: static/app/views/organizationGroupDetails/groupReplays/groupReplays.spec.tsx
text: @user1:
this will be hard to fail, because the number 1 could appear in the component in many places for many reasons. Can we be more specific?