diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 00000000..0a216ba9
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,29 @@
+name: Run tests
+on:
+    push:
+        branches: [develop, main]
+    pull_request:
+        branches: [develop, main]
+jobs:
+    test:
+        name: Run tests and collect coverage
+        runs-on: ubuntu-latest
+        steps:
+            - name: Checkout
+              uses: actions/checkout@v4
+              with:
+                  fetch-depth: 0
+
+            - name: Set up Bun
+              uses: oven-sh/setup-bun@v2
+
+            - name: Install dependencies
+              run: bun install --frozen-lockfile
+
+            - name: Run tests
+              run: bunx jest --coverage
+
+            - name: Upload results to Codecov
+              uses: codecov/codecov-action@v5
+              with:
+                  token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 05df070b..96fc4d57 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,4 +40,6 @@ credentials/
 # The following patterns were generated by expo-cli
 
 expo-env.d.ts
-# @end expo-cli
\ No newline at end of file
+# @end expo-cli
+
+coverage/**/*
\ No newline at end of file
diff --git a/android/app/build.gradle b/android/app/build.gradle
index b251cad6..124ce692 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,7 +91,7 @@ android {
         applicationId 'app.neuland'
         minSdkVersion rootProject.ext.minSdkVersion
         targetSdkVersion rootProject.ext.targetSdkVersion
-        versionCode 275
+        versionCode 279
         versionName "0.11.2"
     }
     signingConfigs {
diff --git a/app.config.json b/app.config.json
index 91873b49..f9cb06f9 100644
--- a/app.config.json
+++ b/app.config.json
@@ -36,7 +36,7 @@
         "android": {
             "package": "app.neuland",
             "userInterfaceStyle": "automatic",
-            "versionCode": 275
+            "versionCode": 279
         },
         "sdkVersion": "52.0.0",
         "experiments": {
diff --git a/bun.lockb b/bun.lockb
index e40b80be..0992dc5f 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 4b74eeb9..24fd8958 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,11 @@
     "licences": "npm-license-crawler -onlyDirectDependencies -json src/data/licenses.json --exclude docs/",
     "prepare": "husky",
     "codegen": "graphql-codegen --config codegen.yml",
-    "changelog": "git cliff --output CHANGELOG.md"
+    "changelog": "git cliff --output CHANGELOG.md",
+    "test": "jest --watch --coverage=false --changedSince=origin/main",
+    "testDebug": "jest -o --watch --coverage=false",
+    "testFinal": "jest",
+    "updateSnapshots": "jest -u --coverage=false"
   },
   "dependencies": {
     "@aptabase/react-native": "^0.3.10",
@@ -125,7 +129,8 @@
     "@types/bun": "latest",
     "@types/color": "^3.0.6",
     "@types/geojson": "^7946.0.15",
-    "@types/node": "^22.10.6",
+    "@types/jest": "^29.5.14",
+    "@types/node": "^22.10.7",
     "@types/prop-types": "^15.7.14",
     "@types/react": "~18.3.18",
     "@types/sanitize-html": "^2.13.0",
@@ -149,7 +154,9 @@
     "expo-dev-client": "~5.0.9",
     "graphql-codegen-typescript-operation-types": "^2.0.1",
     "husky": "^9.1.7",
-    "lint-staged": "^15.3.0",
+    "jest": "~29.7.0",
+    "jest-expo": "~52.0.3",
+    "lint-staged": "^15.4.0",
     "prettier": "3.4.2",
     "prop-types": "^15.8.1",
     "typescript": "^5.7.3"
@@ -166,5 +173,21 @@
     "@th3rdwave/react-navigation-bottom-sheet@0.3.2": "patches/@th3rdwave%2Freact-navigation-bottom-sheet@0.3.2.patch",
     "@aptabase/react-native@0.3.10": "patches/@aptabase%2Freact-native@0.3.10.patch",
     "react-native-drag-sort@2.4.4": "patches/react-native-drag-sort@2.4.4.patch"
-  }
+  },
+  "jest": {
+  "preset": "jest-expo",
+  "transformIgnorePatterns": [
+    "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)"
+  ],
+  "collectCoverage": true,
+  "collectCoverageFrom": [
+    "**/*.{ts,tsx,js,jsx}",
+    "!**/coverage/**",
+    "!**/node_modules/**",
+    "!**/babel.config.js",
+    "!**/expo-env.d.ts",
+    "!**/.expo/**"
+  ]
+}
+
 }
diff --git a/src/localization/i18n.ts b/src/localization/i18n.ts
index 9c4b054c..f01768a0 100644
--- a/src/localization/i18n.ts
+++ b/src/localization/i18n.ts
@@ -49,7 +49,9 @@ export const resources = {
 export const defaultNS = 'en'
 export type LanguageKey = keyof typeof resources
 
-const languageCode = getLocales()[0].languageCode ?? ''
+const locales = getLocales()
+const languageCode =
+    (locales && locales.length > 0 ? locales[0].languageCode : '') ?? ''
 const fallbackLanguage = defaultNS
 const language = Object.keys(resources).includes(languageCode)
     ? languageCode
diff --git a/src/types/utils.ts b/src/types/utils.ts
index 013982d0..4b45f197 100644
--- a/src/types/utils.ts
+++ b/src/types/utils.ts
@@ -100,7 +100,7 @@ export interface ExamTimetableEntry extends Exam {
 
 export interface TimetableSections {
     title: Date
-    data: TimetableEntry[] | ExamEntry[]
+    data: (TimetableEntry | ExamEntry)[]
 }
 
 export interface CalendarEvent {
diff --git a/src/utils/__tests__/timetable-utils-test.ts b/src/utils/__tests__/timetable-utils-test.ts
new file mode 100644
index 00000000..eda39fb5
--- /dev/null
+++ b/src/utils/__tests__/timetable-utils-test.ts
@@ -0,0 +1,182 @@
+import { Exam, FriendlyTimetableEntry, TimetableSections } from '@/types/utils'
+import moment from 'moment'
+
+import {
+    generateKey,
+    getGroupedTimetable,
+    isValidRoom,
+} from '../timetable-utils'
+
+describe('getGroupedTimetable', () => {
+    it('should correctly group and sort timetable entries and exams by date', () => {
+        const timetable: FriendlyTimetableEntry[] = [
+            {
+                date: new Date('2023-10-01T10:00:00Z'),
+                startDate: new Date('2023-10-01T10:00:00Z'),
+                endDate: new Date('2023-10-01T11:00:00Z'),
+                name: 'Lecture 1',
+                shortName: 'Lec 1',
+                rooms: ['Room 1'],
+                lecturer: 'Dr. Smith',
+                course: 'Course 1',
+                studyGroup: 'Group 1',
+                sws: '2',
+                ects: '3',
+                goal: 'Goal 1',
+                contents: 'Contents 1',
+                literature: 'Literature 1',
+            },
+            {
+                date: new Date('2023-10-02T12:00:00Z'),
+                startDate: new Date('2023-10-02T12:00:00Z'),
+                endDate: new Date('2023-10-02T13:00:00Z'),
+                name: 'Lecture 2',
+                shortName: 'Lec 2',
+                rooms: ['Room 2'],
+                lecturer: 'Dr. Johnson',
+                course: 'Course 2',
+                studyGroup: 'Group 2',
+                sws: '2',
+                ects: '3',
+                goal: 'Goal 2',
+                contents: 'Contents 2',
+                literature: 'Literature 2',
+            },
+        ]
+
+        const exams: Exam[] = [
+            {
+                date: new Date('2023-10-01T08:00:00Z'),
+                type: 'LN - schriftliche Prüfung, 90 Minuten',
+                name: 'Exam 1',
+                rooms: 'Room 3',
+                seat: 'Seat 1',
+                notes: 'Notes 1',
+                examiners: ['Examiner 1'],
+                enrollment: new Date('2023-09-01T08:00:00Z'),
+                aids: ['Aid 1'],
+            },
+            {
+                date: new Date('2023-10-02T09:00:00Z'),
+                type: 'SP - schrP90 - schriftliche Prüfung, 90 Minuten',
+                name: 'Exam 2',
+                rooms: 'Room 4',
+                seat: 'Seat 2',
+                notes: 'Notes 2',
+                examiners: ['Examiner 2'],
+                enrollment: new Date('2023-09-02T09:00:00Z'),
+                aids: ['Aid 2'],
+            },
+        ]
+
+        const expectedOutput: TimetableSections[] = [
+            {
+                title: new Date('2023-10-01'),
+                data: [
+                    {
+                        date: new Date('2023-10-01T08:00:00Z'),
+                        type: 'LN - schriftliche Prüfung, 90 Minuten',
+                        name: 'Exam 1',
+                        rooms: 'Room 3',
+                        seat: 'Seat 1',
+                        notes: 'Notes 1',
+                        examiners: ['Examiner 1'],
+                        enrollment: new Date('2023-09-01T08:00:00Z'),
+                        aids: ['Aid 1'],
+                        endDate: moment('2023-10-01T08:00:00Z')
+                            .add(90, 'minutes')
+                            .toDate(),
+                        eventType: 'exam',
+                    },
+                    {
+                        date: new Date('2023-10-01T10:00:00Z'),
+                        startDate: new Date('2023-10-01T10:00:00Z'),
+                        endDate: new Date('2023-10-01T11:00:00Z'),
+                        name: 'Lecture 1',
+                        shortName: 'Lec 1',
+                        rooms: ['Room 1'],
+                        lecturer: 'Dr. Smith',
+                        course: 'Course 1',
+                        studyGroup: 'Group 1',
+                        sws: '2',
+                        ects: '3',
+                        goal: 'Goal 1',
+                        contents: 'Contents 1',
+                        literature: 'Literature 1',
+                        eventType: 'timetable',
+                    },
+                ],
+            },
+            {
+                title: new Date('2023-10-02'),
+                data: [
+                    {
+                        date: new Date('2023-10-02T09:00:00Z'),
+                        type: 'SP - schrP90 - schriftliche Prüfung, 90 Minuten',
+                        name: 'Exam 2',
+                        rooms: 'Room 4',
+                        seat: 'Seat 2',
+                        notes: 'Notes 2',
+                        examiners: ['Examiner 2'],
+                        enrollment: new Date('2023-09-02T09:00:00Z'),
+                        aids: ['Aid 2'],
+                        endDate: moment('2023-10-02T09:00:00Z')
+                            .add(90, 'minutes')
+                            .toDate(),
+                        eventType: 'exam',
+                    },
+                    {
+                        date: new Date('2023-10-02T12:00:00Z'),
+                        startDate: new Date('2023-10-02T12:00:00Z'),
+                        endDate: new Date('2023-10-02T13:00:00Z'),
+                        name: 'Lecture 2',
+                        shortName: 'Lec 2',
+                        rooms: ['Room 2'],
+                        lecturer: 'Dr. Johnson',
+                        course: 'Course 2',
+                        studyGroup: 'Group 2',
+                        sws: '2',
+                        ects: '3',
+                        goal: 'Goal 2',
+                        contents: 'Contents 2',
+                        literature: 'Literature 2',
+                        eventType: 'timetable',
+                    },
+                ],
+            },
+        ]
+
+        const result = getGroupedTimetable(timetable, exams)
+        expect(result).toEqual(expectedOutput)
+    })
+})
+
+describe('generateKey', () => {
+    it('should generate a unique key for a lecture', () => {
+        const lectureName = 'Lecture 1'
+        const startDate = new Date('2023-10-01T10:00:00Z')
+        const room = 'Room 1'
+        const expectedKey = 'Lecture 1-1696154400000-Room 1'
+        const result = generateKey(lectureName, startDate, room)
+        expect(result).toBe(expectedKey)
+    })
+})
+
+describe('isValidRoom', () => {
+    it('should return true for valid room strings', () => {
+        expect(isValidRoom('A101')).toBe(true)
+        expect(isValidRoom('B202')).toBe(true)
+        expect(isValidRoom('C303')).toBe(true)
+        expect(isValidRoom('AU101')).toBe(true)
+        expect(isValidRoom('BU202')).toBe(true)
+        expect(isValidRoom('A32')).toBe(true)
+    })
+
+    it('should return false for invalid room strings', () => {
+        expect(isValidRoom('101')).toBe(false)
+        expect(isValidRoom('Online')).toBe(false)
+        expect(isValidRoom('AB1234')).toBe(false)
+        expect(isValidRoom('A1U01')).toBe(false)
+        expect(isValidRoom('')).toBe(false)
+    })
+})
diff --git a/src/utils/timetable-utils.ts b/src/utils/timetable-utils.ts
index bd48cc27..cd7eed38 100644
--- a/src/utils/timetable-utils.ts
+++ b/src/utils/timetable-utils.ts
@@ -1,4 +1,3 @@
-import API from '@/api/authenticated-api'
 import {
     type CalendarEvent,
     type Exam,
@@ -7,6 +6,7 @@ import {
 } from '@/types/utils'
 import moment from 'moment'
 
+import API from '../api/authenticated-api'
 import { combineDateTime } from './date-utils'
 
 /**