Skip to content

Commit d01fca0

Browse files
joehoyleclaude
andcommitted
Add PHPUnit test suite and GitHub Actions CI
Adds test infrastructure for Foundry using the WordPress test framework (wp-phpunit) with integration tests that run against a real MySQL database. Test coverage: - Model: CRUD lifecycle, state flags, query integration, reload - Query: WHERE clauses, ORDER BY, comparison operators, OR relation, pagination - QueryResults: count, iteration, array access, total available, as_array - Table: DDL create/conform, parse_index variants - Namespace functions: get_primary_column, save_many atomic/rollback/dry_run CI runs across PHP 7.4, 8.0, 8.1, 8.2 with MySQL 8.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8464fea commit d01fca0

File tree

12 files changed

+806
-0
lines changed

12 files changed

+806
-0
lines changed

.github/workflows/phpunit.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: PHPUnit
2+
3+
on: [push, pull_request]
4+
5+
jobs:
6+
test:
7+
runs-on: ubuntu-latest
8+
9+
services:
10+
mysql:
11+
image: mysql:8.0
12+
env:
13+
MYSQL_ROOT_PASSWORD: root
14+
MYSQL_DATABASE: foundry_tests
15+
ports:
16+
- '3306:3306'
17+
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
18+
19+
strategy:
20+
matrix:
21+
php: ['7.4', '8.0', '8.1', '8.2']
22+
23+
steps:
24+
- uses: actions/checkout@v4
25+
26+
- uses: shivammathur/setup-php@v2
27+
with:
28+
php-version: ${{ matrix.php }}
29+
30+
- run: composer install
31+
32+
- run: vendor/bin/phpunit
33+
env:
34+
WP_TESTS_DB_HOST: 127.0.0.1
35+
WP_TESTS_DB_USER: root
36+
WP_TESTS_DB_PASS: root
37+
WP_TESTS_DB_NAME: foundry_tests

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/vendor/
2+
composer.lock
3+
.phpunit.result.cache

composer.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,19 @@
1010
"inc/cli/namespace.php",
1111
"inc/database/namespace.php"
1212
]
13+
},
14+
"require-dev": {
15+
"phpunit/phpunit": "^9.0",
16+
"wp-phpunit/wp-phpunit": "^6.0",
17+
"roots/wordpress-no-content": "^6.0",
18+
"yoast/phpunit-polyfills": "^2.0"
19+
},
20+
"config": {
21+
"allow-plugins": {
22+
"roots/wordpress-core-installer": true
23+
}
24+
},
25+
"extra": {
26+
"wordpress-install-dir": "vendor/roots/wordpress-no-content"
1327
}
1428
}

phpunit.xml.dist

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?xml version="1.0"?>
2+
<phpunit
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
5+
bootstrap="tests/bootstrap.php"
6+
backupGlobals="false"
7+
colors="true"
8+
>
9+
<testsuites>
10+
<testsuite name="foundry">
11+
<directory suffix="Test.php">./tests</directory>
12+
</testsuite>
13+
</testsuites>
14+
</phpunit>

tests/bootstrap.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
$_tests_dir = dirname( __DIR__ ) . '/vendor/wp-phpunit/wp-phpunit';
4+
5+
// Point to our test config.
6+
putenv( 'WP_PHPUNIT__TESTS_CONFIG=' . __DIR__ . '/wp-tests-config.php' );
7+
8+
// Load Composer autoloader.
9+
require_once dirname( __DIR__ ) . '/vendor/autoload.php';
10+
11+
// Load the WP testing framework.
12+
require_once $_tests_dir . '/includes/bootstrap.php';
13+
14+
// Load test helpers.
15+
require_once __DIR__ . '/helpers/class-test-model.php';

tests/database/ModelTest.php

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
namespace Foundry\Tests\Database;
4+
5+
use Foundry\Tests\Test_Model;
6+
use Foundry\Database\QueryResults;
7+
8+
class ModelTest extends \WP_UnitTestCase {
9+
10+
public static function set_up_before_class() {
11+
parent::set_up_before_class();
12+
Test_Model::ensure_table();
13+
global $wpdb;
14+
$wpdb->query( 'TRUNCATE TABLE ' . Test_Model::get_table_name() );
15+
}
16+
17+
public function test_create_and_get() {
18+
$model = new Test_Model();
19+
$model->set_name( 'Alice' );
20+
$model->set_status( 'active' );
21+
$model->set_value( 42 );
22+
23+
$result = $model->save();
24+
$this->assertTrue( $result );
25+
26+
$id = $model->get_id();
27+
$this->assertNotNull( $id );
28+
29+
$fetched = Test_Model::get( $id );
30+
$this->assertNotNull( $fetched );
31+
$this->assertEquals( 'Alice', $fetched->get_name() );
32+
$this->assertEquals( 'active', $fetched->get_status() );
33+
$this->assertEquals( 42, $fetched->get_value() );
34+
}
35+
36+
public function test_update() {
37+
$model = new Test_Model();
38+
$model->set_name( 'Bob' );
39+
$model->save();
40+
41+
$id = $model->get_id();
42+
43+
$model->set_name( 'Bobby' );
44+
$result = $model->save();
45+
$this->assertTrue( $result );
46+
47+
$fetched = Test_Model::get( $id );
48+
$this->assertEquals( 'Bobby', $fetched->get_name() );
49+
}
50+
51+
public function test_delete() {
52+
$model = new Test_Model();
53+
$model->set_name( 'Charlie' );
54+
$model->save();
55+
56+
$id = $model->get_id();
57+
58+
$result = $model->delete();
59+
$this->assertTrue( $result );
60+
61+
$fetched = Test_Model::get( $id );
62+
$this->assertNull( $fetched );
63+
}
64+
65+
public function test_is_new() {
66+
$model = new Test_Model();
67+
$this->assertTrue( $model->is_new() );
68+
69+
$model->set_name( 'Test' );
70+
$model->save();
71+
$this->assertFalse( $model->is_new() );
72+
}
73+
74+
public function test_is_modified() {
75+
$model = new Test_Model();
76+
$model->set_name( 'Test' );
77+
$model->save();
78+
79+
$this->assertFalse( $model->is_modified() );
80+
81+
$model->set_name( 'Updated' );
82+
$this->assertTrue( $model->is_modified() );
83+
}
84+
85+
public function test_is_deleted() {
86+
$model = new Test_Model();
87+
$model->set_name( 'Test' );
88+
$model->save();
89+
90+
$this->assertFalse( $model->is_deleted() );
91+
92+
$model->delete();
93+
$this->assertTrue( $model->is_deleted() );
94+
}
95+
96+
public function test_query_returns_results() {
97+
global $wpdb;
98+
$table = Test_Model::get_table_name();
99+
100+
$wpdb->insert( $table, [ 'name' => 'Alice', 'status' => 'active', 'value' => 10 ] );
101+
$wpdb->insert( $table, [ 'name' => 'Bob', 'status' => 'draft', 'value' => 20 ] );
102+
103+
$results = Test_Model::query( [ 'status' => 'active' ] )->get_results();
104+
$this->assertNotWPError( $results );
105+
$this->assertInstanceOf( QueryResults::class, $results );
106+
$this->assertCount( 1, $results );
107+
$this->assertEquals( 'Alice', $results[0]->get_name() );
108+
}
109+
110+
public function test_query_pagination() {
111+
global $wpdb;
112+
$table = Test_Model::get_table_name();
113+
114+
for ( $i = 1; $i <= 5; $i++ ) {
115+
$wpdb->insert( $table, [ 'name' => "Item $i", 'status' => 'active', 'value' => $i ] );
116+
}
117+
118+
$results = Test_Model::query(
119+
[],
120+
[ 'page' => 1, 'per_page' => 2 ]
121+
)->get_results();
122+
123+
$this->assertNotWPError( $results );
124+
$this->assertCount( 2, $results );
125+
$this->assertEquals( 5, $results->get_total_available() );
126+
}
127+
128+
public function test_reload() {
129+
$model = new Test_Model();
130+
$model->set_name( 'Original' );
131+
$model->save();
132+
133+
$model->set_name( 'Changed' );
134+
$this->assertTrue( $model->is_modified() );
135+
$this->assertEquals( 'Changed', $model->get_name() );
136+
137+
$model->reload();
138+
$this->assertFalse( $model->is_modified() );
139+
$this->assertEquals( 'Original', $model->get_name() );
140+
}
141+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
namespace Foundry\Tests\Database;
4+
5+
use Foundry\Tests\Test_Model;
6+
use Foundry\Tests\Failing_Test_Model;
7+
use function Foundry\Database\get_primary_column;
8+
use function Foundry\Database\save_many;
9+
10+
class NamespaceFunctionsTest extends \WP_UnitTestCase {
11+
12+
public static function set_up_before_class() {
13+
parent::set_up_before_class();
14+
Test_Model::ensure_table();
15+
}
16+
17+
/**
18+
* Clean up after each test since save_many uses its own transactions
19+
* which interfere with the WP test framework's transaction rollback.
20+
*
21+
* TRUNCATE is DDL (implicit commit, not rollback-able) so it reliably
22+
* cleans up even when the WP framework's transaction state is broken
23+
* by save_many's nested START TRANSACTION / COMMIT.
24+
*/
25+
public function tear_down() {
26+
global $wpdb;
27+
$wpdb->query( 'TRUNCATE TABLE ' . Test_Model::get_table_name() );
28+
parent::tear_down();
29+
}
30+
31+
public function test_get_primary_column() {
32+
$schema = Test_Model::get_table_schema();
33+
$primary = get_primary_column( $schema );
34+
$this->assertEquals( 'id', $primary );
35+
}
36+
37+
public function test_save_many_atomic() {
38+
$model_a = new Test_Model();
39+
$model_a->set_name( 'Atomic A' );
40+
41+
$model_b = new Test_Model();
42+
$model_b->set_name( 'Atomic B' );
43+
44+
$result = save_many( [ $model_a, $model_b ] );
45+
$this->assertTrue( $result );
46+
47+
// Verify both were saved.
48+
$this->assertNotNull( $model_a->get_id() );
49+
$this->assertNotNull( $model_b->get_id() );
50+
51+
$fetched_a = Test_Model::get( $model_a->get_id() );
52+
$fetched_b = Test_Model::get( $model_b->get_id() );
53+
$this->assertNotNull( $fetched_a );
54+
$this->assertNotNull( $fetched_b );
55+
$this->assertEquals( 'Atomic A', $fetched_a->get_name() );
56+
$this->assertEquals( 'Atomic B', $fetched_b->get_name() );
57+
}
58+
59+
public function test_save_many_rollback_on_error() {
60+
$model_a = new Test_Model();
61+
$model_a->set_name( 'Should Not Persist' );
62+
63+
// This model's save() always returns WP_Error.
64+
$model_b = new Failing_Test_Model();
65+
$model_b->set_name( 'Will Fail' );
66+
67+
$result = save_many( [ $model_a, $model_b ] );
68+
$this->assertWPError( $result );
69+
70+
// model_a was saved within the transaction, but it should have been rolled back.
71+
// Since model_a got an insert_id before the rollback, check the DB directly.
72+
global $wpdb;
73+
$table = Test_Model::get_table_name();
74+
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table WHERE name = 'Should Not Persist'" );
75+
$this->assertEquals( 0, $count );
76+
}
77+
78+
public function test_save_many_dry_run() {
79+
$model_a = new Test_Model();
80+
$model_a->set_name( 'Dry Run A' );
81+
82+
$model_b = new Test_Model();
83+
$model_b->set_name( 'Dry Run B' );
84+
85+
$result = save_many( [ $model_a, $model_b ], true );
86+
$this->assertTrue( $result );
87+
88+
// Dry run rolls back — data should not persist.
89+
global $wpdb;
90+
$table = Test_Model::get_table_name();
91+
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table WHERE name LIKE 'Dry Run%'" );
92+
$this->assertEquals( 0, $count );
93+
}
94+
}

0 commit comments

Comments
 (0)