• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 27461882718

13 Jun 2026 08:39AM UTC coverage: 89.056% (+0.07%) from 88.991%
27461882718

Pull #10302

github

web-flow
Merge b8adcfc91 into 3f961107f
Pull Request #10302: feat: add strict field protection for Models

28 of 31 new or added lines in 4 files covered. (90.32%)

43 existing lines in 6 files now uncovered.

24892 of 27951 relevant lines covered (89.06%)

225.43 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.75
/system/Test/CIUnitTestCase.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Test;
15

16
use CodeIgniter\CodeIgniter;
17
use CodeIgniter\Config\Factories;
18
use CodeIgniter\Database\BaseConnection;
19
use CodeIgniter\Database\MigrationRunner;
20
use CodeIgniter\Database\Seeder;
21
use CodeIgniter\Events\Events;
22
use CodeIgniter\HTTP\Header;
23
use CodeIgniter\Router\RouteCollection;
24
use CodeIgniter\Session\Handlers\ArrayHandler;
25
use CodeIgniter\Test\Mock\MockCache;
26
use CodeIgniter\Test\Mock\MockCodeIgniter;
27
use CodeIgniter\Test\Mock\MockEmail;
28
use CodeIgniter\Test\Mock\MockSession;
29
use Config\App;
30
use Config\Autoload;
31
use Config\Email;
32
use Config\Modules;
33
use Config\Services;
34
use Config\Session;
35
use Exception;
36
use PHPUnit\Framework\TestCase;
37

38
/**
39
 * Framework test case for PHPUnit.
40
 */
41
abstract class CIUnitTestCase extends TestCase
42
{
43
    use ReflectionHelper;
44

45
    /**
46
     * @var CodeIgniter
47
     */
48
    protected $app;
49

50
    /**
51
     * Methods to run during setUp.
52
     *
53
     * WARNING: Do not override unless you know exactly what you are doing.
54
     *          This property may be deprecated in the future.
55
     *
56
     * @var list<string> array of methods
57
     */
58
    protected $setUpMethods = [
59
        'resetFactories',
60
        'mockCache',
61
        'mockEmail',
62
        'mockSession',
63
    ];
64

65
    /**
66
     * Methods to run during tearDown.
67
     *
68
     * WARNING: This property may be deprecated in the future.
69
     *
70
     * @var list<string> array of methods
71
     */
72
    protected $tearDownMethods = [];
73

74
    /**
75
     * Store of identified traits.
76
     *
77
     * @var array<class-string, class-string>|null
78
     */
79
    private ?array $traits = null;
80

81
    // --------------------------------------------------------------------
82
    // Database Properties
83
    // --------------------------------------------------------------------
84

85
    /**
86
     * Should run db migration?
87
     *
88
     * @var bool
89
     */
90
    protected $migrate = true;
91

92
    /**
93
     * Should run db migration only once?
94
     *
95
     * @var bool
96
     */
97
    protected $migrateOnce = false;
98

99
    /**
100
     * Should run seeding only once?
101
     *
102
     * @var bool
103
     */
104
    protected $seedOnce = false;
105

106
    /**
107
     * Should the db be refreshed before test?
108
     *
109
     * @var bool
110
     */
111
    protected $refresh = true;
112

113
    /**
114
     * The seed file(s) used for all tests within this test case.
115
     * Should be fully-namespaced or relative to $basePath.
116
     *
117
     * @var ''|class-string<Seeder>|list<class-string<Seeder>>
118
     */
119
    protected $seed = '';
120

121
    /**
122
     * The path to the seeds directory.
123
     * Allows overriding the default application directories.
124
     *
125
     * @var string
126
     */
127
    protected $basePath = TESTPATH . '_support/Database';
128

129
    /**
130
     * The namespace(s) to help us find the migration classes.
131
     * `null` is equivalent to running `spark migrate --all`.
132
     * Note that running "all" runs migrations in date order,
133
     * but specifying namespaces runs them in namespace order (then date).
134
     *
135
     * @var list<string>|string|null
136
     */
137
    protected $namespace = 'Tests\Support';
138

139
    /**
140
     * The name of the database group to connect to.
141
     * If not present, will use the defaultGroup.
142
     *
143
     * @var non-empty-string
144
     */
145
    protected $DBGroup = 'tests';
146

147
    /**
148
     * Our database connection.
149
     *
150
     * @var BaseConnection
151
     */
152
    protected $db;
153

154
    /**
155
     * Migration Runner instance.
156
     *
157
     * @var MigrationRunner|null
158
     */
159
    protected $migrations;
160

161
    /**
162
     * Seeder instance.
163
     *
164
     * @var Seeder|null
165
     */
166
    protected $seeder;
167

168
    /**
169
     * Stores information needed to remove any
170
     * rows inserted via $this->hasInDatabase().
171
     *
172
     * @var list<array<int|string, mixed>>
173
     */
174
    protected $insertCache = [];
175

176
    // --------------------------------------------------------------------
177
    // Feature Properties
178
    // --------------------------------------------------------------------
179

180
    /**
181
     * If present, will override application
182
     * routes when using call().
183
     *
184
     * @var RouteCollection|null
185
     */
186
    protected $routes;
187

188
    /**
189
     * Values to be set in the SESSION global
190
     * before running the test.
191
     *
192
     * @var array<int|string, mixed>
193
     */
194
    protected $session = [];
195

196
    /**
197
     * Enabled auto clean op buffer after request call.
198
     *
199
     * @var bool
200
     */
201
    protected $clean = true;
202

203
    /**
204
     * Custom request's headers.
205
     *
206
     * @var array<string, Header|list<Header>>
207
     */
208
    protected $headers = [];
209

210
    /**
211
     * Allows for formatting the request body to what
212
     * the controller is going to expect.
213
     *
214
     * @var string
215
     */
216
    protected $bodyFormat = '';
217

218
    /**
219
     * Allows for directly setting the body to what
220
     * it needs to be.
221
     *
222
     * @var mixed
223
     */
224
    protected $requestBody = '';
225

226
    // --------------------------------------------------------------------
227
    // Staging
228
    // --------------------------------------------------------------------
229

230
    /**
231
     * Load the helpers.
232
     */
233
    public static function setUpBeforeClass(): void
234
    {
235
        parent::setUpBeforeClass();
294✔
236

237
        helper(['url', 'test']);
294✔
238
    }
239

240
    protected function setUp(): void
241
    {
242
        parent::setUp();
8,118✔
243

244
        if (! $this->app instanceof CodeIgniter) {
8,118✔
245
            $this->app = $this->createApplication();
8,118✔
246
        }
247

248
        foreach ($this->setUpMethods as $method) {
8,118✔
249
            $this->{$method}();
8,118✔
250
        }
251

252
        // Check for the database trait
253
        if (method_exists($this, 'setUpDatabase')) {
8,118✔
254
            $this->setUpDatabase();
947✔
255
        }
256

257
        // Check for other trait methods
258
        $this->callTraitMethods('setUp');
8,118✔
259
    }
260

261
    protected function tearDown(): void
262
    {
263
        parent::tearDown();
7,931✔
264

265
        foreach ($this->tearDownMethods as $method) {
7,931✔
266
            $this->{$method}();
×
267
        }
268

269
        // Check for the database trait
270
        if (method_exists($this, 'tearDownDatabase')) {
7,931✔
271
            $this->tearDownDatabase();
946✔
272
        }
273

274
        // Check for other trait methods
275
        $this->callTraitMethods('tearDown');
7,931✔
276
    }
277

278
    /**
279
     * Checks for traits with corresponding
280
     * methods for setUp or tearDown.
281
     *
282
     * @param 'setUp'|'tearDown' $stage
283
     */
284
    private function callTraitMethods(string $stage): void
285
    {
286
        if ($this->traits === null) {
8,303✔
287
            $this->traits = class_uses_recursive($this);
8,303✔
288
        }
289

290
        foreach ($this->traits as $trait) {
8,303✔
291
            $method = $stage . class_basename($trait);
8,303✔
292

293
            if (method_exists($this, $method)) {
8,303✔
294
                $this->{$method}();
356✔
295
            }
296
        }
297
    }
298

299
    // --------------------------------------------------------------------
300
    // Mocking
301
    // --------------------------------------------------------------------
302

303
    /**
304
     * Resets shared instanced for all Factories components.
305
     *
306
     * @return void
307
     */
308
    protected function resetFactories()
309
    {
310
        Factories::reset();
8,118✔
311
    }
312

313
    /**
314
     * Resets shared instanced for all Services.
315
     *
316
     * @return void
317
     */
318
    protected function resetServices(bool $initAutoloader = true)
319
    {
320
        Services::reset($initAutoloader);
1,901✔
321
    }
322

323
    /**
324
     * Injects the mock Cache driver to prevent filesystem collisions.
325
     *
326
     * @return void
327
     */
328
    protected function mockCache()
329
    {
330
        Services::injectMock('cache', new MockCache());
8,118✔
331
    }
332

333
    /**
334
     * Injects the mock email driver so no emails really send.
335
     *
336
     * @return void
337
     */
338
    protected function mockEmail()
339
    {
340
        Services::injectMock('email', new MockEmail(config(Email::class)));
8,118✔
341
    }
342

343
    /**
344
     * Injects the mock session driver into Services.
345
     *
346
     * @return void
347
     */
348
    protected function mockSession()
349
    {
350
        $_SESSION = [];
8,118✔
351

352
        $config  = config(Session::class);
8,118✔
353
        $session = new MockSession(new ArrayHandler($config, '0.0.0.0'), $config);
8,118✔
354

355
        Services::injectMock('session', $session);
8,118✔
356
    }
357

358
    // --------------------------------------------------------------------
359
    // Assertions
360
    // --------------------------------------------------------------------
361

362
    /**
363
     * Custom function to hook into CodeIgniter's Logging mechanism
364
     * to check if certain messages were logged during code execution.
365
     *
366
     * @param string|null $expectedMessage
367
     *
368
     * @return bool
369
     */
370
    public function assertLogged(string $level, $expectedMessage = null)
371
    {
372
        $result = TestLogger::didLog($level, $expectedMessage);
27✔
373

374
        $this->assertTrue($result, sprintf(
27✔
375
            'Failed asserting that expected message "%s" with level "%s" was logged.',
27✔
376
            $expectedMessage ?? '',
27✔
377
            $level,
27✔
378
        ));
27✔
379

380
        return $result;
27✔
381
    }
382

383
    /**
384
     * Asserts that there is a log record that contains `$logMessage` in the message.
385
     */
386
    public function assertLogContains(string $level, string $logMessage, string $message = ''): void
387
    {
388
        $this->assertTrue(
5✔
389
            TestLogger::didLog($level, $logMessage, false),
5✔
390
            $message !== '' ? $message : sprintf(
5✔
391
                'Failed asserting that logs have a record of message containing "%s" with level "%s".',
5✔
392
                $logMessage,
5✔
393
                $level,
5✔
394
            ),
5✔
395
        );
5✔
396
    }
397

398
    /**
399
     * Hooks into CodeIgniter's Events system to check if a specific
400
     * event was triggered or not.
401
     *
402
     * @throws Exception
403
     */
404
    public function assertEventTriggered(string $eventName): bool
405
    {
406
        $found     = false;
1✔
407
        $eventName = strtolower($eventName);
1✔
408

409
        foreach (Events::getPerformanceLogs() as $log) {
1✔
410
            if ($log['event'] !== $eventName) {
1✔
411
                continue;
1✔
412
            }
413

414
            $found = true;
1✔
415
            break;
1✔
416
        }
417

418
        $this->assertTrue($found);
1✔
419

420
        return $found;
1✔
421
    }
422

423
    /**
424
     * Hooks into xdebug's headers capture, looking for presence of
425
     * a specific header emitted.
426
     *
427
     * @param string $header The leading portion of the header we are looking for
428
     */
429
    public function assertHeaderEmitted(string $header, bool $ignoreCase = false): void
430
    {
431
        $this->assertNotNull(
12✔
432
            $this->getHeaderEmitted($header, $ignoreCase, __METHOD__),
12✔
433
            "Didn't find header for {$header}",
12✔
434
        );
12✔
435
    }
436

437
    /**
438
     * Hooks into xdebug's headers capture, looking for absence of
439
     * a specific header emitted.
440
     *
441
     * @param string $header The leading portion of the header we don't want to find
442
     */
443
    public function assertHeaderNotEmitted(string $header, bool $ignoreCase = false): void
444
    {
445
        $this->assertNull(
3✔
446
            $this->getHeaderEmitted($header, $ignoreCase, __METHOD__),
3✔
447
            "Found header for {$header}",
3✔
448
        );
3✔
449
    }
450

451
    /**
452
     * Custom function to test that two values are "close enough".
453
     * This is intended for extended execution time testing,
454
     * where the result is close but not exactly equal to the
455
     * expected time, for reasons beyond our control.
456
     *
457
     * @param float|int $actual
458
     *
459
     * @return void
460
     *
461
     * @throws Exception
462
     */
463
    public function assertCloseEnough(int $expected, $actual, string $message = '', int $tolerance = 1)
464
    {
465
        $difference = abs($expected - (int) floor($actual));
8✔
466

467
        $this->assertLessThanOrEqual($tolerance, $difference, $message);
8✔
468
    }
469

470
    /**
471
     * Custom function to test that two values are "close enough".
472
     * This is intended for extended execution time testing,
473
     * where the result is close but not exactly equal to the
474
     * expected time, for reasons beyond our control.
475
     *
476
     * @param mixed $expected
477
     * @param mixed $actual
478
     *
479
     * @return bool|null
480
     *
481
     * @throws Exception
482
     */
483
    public function assertCloseEnoughString($expected, $actual, string $message = '', int $tolerance = 1)
484
    {
485
        $expected = (string) $expected;
18✔
486
        $actual   = (string) $actual;
18✔
487
        if (strlen($expected) !== strlen($actual)) {
18✔
488
            return false;
1✔
489
        }
490

491
        try {
492
            $expected   = (int) substr($expected, -2);
17✔
493
            $actual     = (int) substr($actual, -2);
17✔
494
            $difference = abs($expected - $actual);
17✔
495

496
            $this->assertLessThanOrEqual($tolerance, $difference, $message);
17✔
497
        } catch (Exception) {
1✔
498
            return false;
1✔
499
        }
500

501
        return null;
17✔
502
    }
503

504
    /**
505
     * Asserts that two SQL strings are the same, ignoring newlines in the actual SQL.
506
     */
507
    public function assertSameSql(string $expected, string $actual, string $message = ''): void
508
    {
509
        $this->assertSame($expected, str_replace(["\r\n", "\r", "\n"], ' ', $actual), $message);
271✔
510
    }
511

512
    // --------------------------------------------------------------------
513
    // Utility
514
    // --------------------------------------------------------------------
515

516
    /**
517
     * Loads up an instance of CodeIgniter
518
     * and gets the environment setup.
519
     *
520
     * @return CodeIgniter
521
     */
522
    protected function createApplication()
523
    {
524
        // Initialize the autoloader.
525
        service('autoloader')->initialize(new Autoload(), new Modules());
8,118✔
526

527
        $app = new MockCodeIgniter(new App());
8,118✔
528
        $app->initialize();
8,118✔
529

530
        return $app;
8,118✔
531
    }
532

533
    /**
534
     * Return first matching emitted header.
535
     */
536
    protected function getHeaderEmitted(string $header, bool $ignoreCase = false, string $method = __METHOD__): ?string
537
    {
538
        if (! function_exists('xdebug_get_headers')) {
51✔
UNCOV
539
            $this->markTestSkipped($method . '() requires xdebug.');
×
540
        }
541

542
        foreach (xdebug_get_headers() as $emittedHeader) {
51✔
543
            $found = $ignoreCase
51✔
544
                ? (str_starts_with(strtolower($emittedHeader), strtolower($header)))
4✔
545
                : (str_starts_with($emittedHeader, $header));
48✔
546

547
            if ($found) {
51✔
548
                return $emittedHeader;
48✔
549
            }
550
        }
551

552
        return null;
4✔
553
    }
554
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc