diff --git a/static/app/views/codecov/tests/settings.tsx b/static/app/views/codecov/tests/settings.tsx
new file mode 100644
index 00000000000000..22cd2e706dd063
--- /dev/null
+++ b/static/app/views/codecov/tests/settings.tsx
@@ -0,0 +1,6 @@
+import type {ValidSort} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable';
+
+export const DEFAULT_SORT: ValidSort = {
+ field: 'commitsFailed',
+ kind: 'desc',
+};
diff --git a/static/app/views/codecov/tests/testAnalyticsTable/sortableHeader.tsx b/static/app/views/codecov/tests/testAnalyticsTable/sortableHeader.tsx
new file mode 100644
index 00000000000000..8ed5c17e4676fa
--- /dev/null
+++ b/static/app/views/codecov/tests/testAnalyticsTable/sortableHeader.tsx
@@ -0,0 +1,79 @@
+import type {ReactNode} from 'react';
+import styled from '@emotion/styled';
+
+import Link from 'sentry/components/links/link';
+import QuestionTooltip from 'sentry/components/questionTooltip';
+import {IconArrow} from 'sentry/icons';
+import {space} from 'sentry/styles/space';
+import type {Sort} from 'sentry/utils/discover/fields';
+import {useLocation} from 'sentry/utils/useLocation';
+
+type HeaderParams = {
+ alignment: string;
+ fieldName: string;
+ label: string;
+ sort: undefined | Sort;
+ tooltip?: string | ReactNode;
+};
+
+function SortableHeader({fieldName, label, sort, tooltip, alignment}: HeaderParams) {
+ const location = useLocation();
+
+ const arrowDirection = sort?.kind === 'asc' ? 'up' : 'down';
+ const sortArrow = ;
+
+ return (
+
+
+ {label} {sort?.field === fieldName && sortArrow}
+
+ {tooltip ? (
+
+ ) : null}
+
+ );
+}
+
+const HeaderCell = styled('div')<{alignment: string}>`
+ display: block;
+ width: 100%;
+ text-align: ${p => (p.alignment === 'left' ? 'left' : 'right')};
+`;
+
+const StyledLink = styled(Link)`
+ color: inherit;
+
+ :hover {
+ color: inherit;
+ }
+
+ svg {
+ vertical-align: top;
+ }
+`;
+
+const StyledQuestionTooltip = styled(QuestionTooltip)`
+ margin-left: ${space(0.5)};
+`;
+
+export default SortableHeader;
diff --git a/static/app/views/codecov/tests/testAnalyticsTable/tableBody.tsx b/static/app/views/codecov/tests/testAnalyticsTable/tableBody.tsx
new file mode 100644
index 00000000000000..0d48f44aac2df4
--- /dev/null
+++ b/static/app/views/codecov/tests/testAnalyticsTable/tableBody.tsx
@@ -0,0 +1,79 @@
+import styled from '@emotion/styled';
+
+import {Tag} from 'sentry/components/core/badge/tag';
+import {DateTime} from 'sentry/components/dateTime';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import {space} from 'sentry/styles/space';
+import {
+ type Column,
+ RIGHT_ALIGNED_FIELDS,
+ type Row,
+} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable';
+
+interface TableBodyProps {
+ column: Column;
+ row: Row;
+}
+
+export function renderTableBody({column, row}: TableBodyProps) {
+ const key = column.key;
+ const value = row[key];
+ const alignment = RIGHT_ALIGNED_FIELDS.has(key) ? 'right' : 'left';
+
+ if (key === 'testName') {
+ return {value};
+ }
+
+ if (key === 'averageDurationMs') {
+ return (
+
+
+
+ );
+ }
+
+ if (key === 'flakeRate') {
+ const isBrokenTest = row.isBrokenTest;
+ return (
+
+ {isBrokenTest && Broken test}
+ {value}%
+
+ );
+ }
+
+ if (key === 'commitsFailed') {
+ return {value};
+ }
+
+ if (key === 'lastRun') {
+ return (
+
+
+
+ );
+ }
+
+ return {value};
+}
+
+export const Container = styled('div')<{alignment: string}>`
+ ${p => p.theme.overflowEllipsis};
+ font-family: ${p => p.theme.text.familyMono};
+ text-align: ${p => (p.alignment === 'left' ? 'left' : 'right')};
+`;
+
+const DateContainer = styled('div')`
+ color: ${p => p.theme.tokens.content.muted};
+ text-align: 'left';
+`;
+
+const NumberContainer = styled('div')`
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ ${p => p.theme.overflowEllipsis};
+`;
+
+const StyledTag = styled(Tag)`
+ margin-right: ${space(1.5)};
+`;
diff --git a/static/app/views/codecov/tests/testAnalyticsTable/tableHeader.tsx b/static/app/views/codecov/tests/testAnalyticsTable/tableHeader.tsx
new file mode 100644
index 00000000000000..ce68699141d1c1
--- /dev/null
+++ b/static/app/views/codecov/tests/testAnalyticsTable/tableHeader.tsx
@@ -0,0 +1,46 @@
+import {tct} from 'sentry/locale';
+import type {Sort} from 'sentry/utils/discover/fields';
+import SortableHeader from 'sentry/views/codecov/tests/testAnalyticsTable/sortableHeader';
+import type {Column} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable';
+import {RIGHT_ALIGNED_FIELDS} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable';
+
+type TableHeaderParams = {
+ column: Column;
+ sort?: Sort;
+};
+
+type FlakyTestTooltipProps = {
+ date: string;
+};
+
+function FlakyTestsTooltip({date}: FlakyTestTooltipProps) {
+ return (
+
+ {tct(
+ `Shows how often a flake occurs by tracking how many times a test goes from fail to pass or pass to fail on a given branch and commit within the last [date]`,
+ {date}
+ )}
+ .
+
+ );
+}
+
+export const renderTableHeader = ({column, sort}: TableHeaderParams) => {
+ const {key, name} = column;
+ // TODO: adjust when the date selector is completed
+ const date = '30 days';
+
+ const alignment = RIGHT_ALIGNED_FIELDS.has(key) ? 'right' : 'left';
+
+ return (
+ ,
+ })}
+ />
+ );
+};
diff --git a/static/app/views/codecov/tests/testAnalyticsTable/testAnalyticsTable.tsx b/static/app/views/codecov/tests/testAnalyticsTable/testAnalyticsTable.tsx
new file mode 100644
index 00000000000000..eb9b9de0060b6d
--- /dev/null
+++ b/static/app/views/codecov/tests/testAnalyticsTable/testAnalyticsTable.tsx
@@ -0,0 +1,97 @@
+import type {GridColumnHeader} from 'sentry/components/gridEditable';
+import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
+import {t} from 'sentry/locale';
+import type {Sort} from 'sentry/utils/discover/fields';
+import {renderTableBody} from 'sentry/views/codecov/tests/testAnalyticsTable/tableBody';
+import {renderTableHeader} from 'sentry/views/codecov/tests/testAnalyticsTable/tableHeader';
+
+type TestAnalyticsTableResponse = {
+ averageDurationMs: number;
+ commitsFailed: number;
+ flakeRate: number;
+ isBrokenTest: boolean;
+ lastRun: string;
+ testName: string;
+};
+
+export type Row = Pick<
+ TestAnalyticsTableResponse,
+ | 'testName'
+ | 'averageDurationMs'
+ | 'flakeRate'
+ | 'commitsFailed'
+ | 'lastRun'
+ | 'isBrokenTest'
+>;
+export type Column = GridColumnHeader<
+ 'testName' | 'averageDurationMs' | 'flakeRate' | 'commitsFailed' | 'lastRun'
+>;
+
+export type ValidSort = Sort & {
+ field: (typeof SORTABLE_FIELDS)[number];
+};
+
+const COLUMNS_ORDER: Column[] = [
+ {key: 'testName', name: t('Test Name'), width: COL_WIDTH_UNDEFINED},
+ {key: 'averageDurationMs', name: t('Avg. Duration'), width: COL_WIDTH_UNDEFINED},
+ {key: 'flakeRate', name: t('Flake Rate'), width: COL_WIDTH_UNDEFINED},
+ {key: 'commitsFailed', name: t('Commits Failed'), width: COL_WIDTH_UNDEFINED},
+ {key: 'lastRun', name: t('Last Run'), width: COL_WIDTH_UNDEFINED},
+];
+
+export const RIGHT_ALIGNED_FIELDS = new Set([
+ 'averageDurationMs',
+ 'flakeRate',
+ 'commitsFailed',
+]);
+
+export const SORTABLE_FIELDS = [
+ 'testName',
+ 'averageDurationMs',
+ 'flakeRate',
+ 'commitsFailed',
+ 'lastRun',
+] as const;
+
+export function isAValidSort(sort: Sort): sort is ValidSort {
+ return (SORTABLE_FIELDS as unknown as string[]).includes(sort.field);
+}
+
+interface Props {
+ response: {
+ data: Row[];
+ isLoading: boolean;
+ error?: Error | null;
+ };
+ sort: ValidSort;
+}
+
+export default function TestAnalyticsTable({response, sort}: Props) {
+ const {data, isLoading} = response;
+
+ return (
+
+ renderTableHeader({
+ column,
+ sort,
+ }),
+ renderBodyCell: (column, row) => renderTableBody({column, row}),
+ }}
+ />
+ );
+}
diff --git a/static/app/views/codecov/tests/tests.tsx b/static/app/views/codecov/tests/tests.tsx
index 17a681b73669d0..5322d844323768 100644
--- a/static/app/views/codecov/tests/tests.tsx
+++ b/static/app/views/codecov/tests/tests.tsx
@@ -1,10 +1,18 @@
import styled from '@emotion/styled';
import {DatePicker} from 'sentry/components/codecov/datePicker/datePicker';
+// import TestAnalyticsTable from 'sentry/components/codecov/testAnalytics/testAnalyticsTable';
import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
import {space} from 'sentry/styles/space';
+import {decodeSorts} from 'sentry/utils/queryString';
+import {useLocation} from 'sentry/utils/useLocation';
+import {DEFAULT_SORT} from 'sentry/views/codecov/tests/settings';
import {Summaries} from 'sentry/views/codecov/tests/summaries/summaries';
+import type {ValidSort} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable';
+import TestAnalyticsTable, {
+ isAValidSort,
+} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable';
const DEFAULT_CODECOV_DATETIME_SELECTION = {
start: null,
@@ -13,7 +21,47 @@ const DEFAULT_CODECOV_DATETIME_SELECTION = {
period: '24h',
};
+// TODO: Sorting will only work once this is connected to the API
+const fakeApiResponse = {
+ data: [
+ {
+ testName:
+ 'tests.symbolicator.test_unreal_full.SymbolicatorUnrealIntegrationTest::test_unreal_crash_with_attachments',
+ averageDurationMs: 4,
+ flakeRate: 0.4,
+ commitsFailed: 1,
+ lastRun: '2025-04-17T22:26:19.486793+00:00',
+ isBrokenTest: false,
+ },
+ {
+ testName:
+ 'graphql_api/tests/test_owner.py::TestOwnerType::test_fetch_current_user_is_not_okta_authenticated',
+ averageDurationMs: 4370,
+ flakeRate: 0,
+ commitsFailed: 5,
+ lastRun: '2025-04-16T22:26:19.486793+00:00',
+ isBrokenTest: true,
+ },
+ {
+ testName: 'graphql_api/tests/test_owner.py',
+ averageDurationMs: 10032,
+ flakeRate: 1,
+ commitsFailed: 3,
+ lastRun: '2025-02-16T22:26:19.486793+00:00',
+ isBrokenTest: false,
+ },
+ ],
+ isLoading: false,
+ isError: false,
+};
+
export default function TestsPage() {
+ const location = useLocation();
+
+ const sorts: [ValidSort] = [
+ decodeSorts(location.query?.sort).find(isAValidSort) ?? DEFAULT_SORT,
+ ];
+
return (
Test Analytics
@@ -26,6 +74,7 @@ export default function TestsPage() {
{/* TODO: Conditionally show these if the branch we're in is the main branch */}
+
);
}