viewtrender / php-youtube-testkit-laravel
Laravel integration for php-youtube-testkit
Package info
github.com/viewtrender/php-youtube-testkit-laravel
pkg:composer/viewtrender/php-youtube-testkit-laravel
Requires
- php: ^8.3
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- viewtrender/php-youtube-testkit-core: ^0.6
Requires (Dev)
- orchestra/testbench: ^10.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel integration for mocking YouTube Data, Analytics, and Reporting APIs in tests.
Overview
Laravel service provider, facades, and automatic container swaps for viewtrender/php-youtube-testkit-core. Supports three YouTube APIs:
- YouTube Data API — videos, channels, playlists, search, comments
- YouTube Analytics API — on-demand metrics queries
- YouTube Reporting API — bulk data exports and scheduled jobs
When you call fake() on any API facade, the service provider replaces the Google Service container binding with a fake instance — controllers that type-hint the service receive the fake automatically.
Installation
composer require --dev viewtrender/php-youtube-testkit-laravel
The service provider is auto-discovered. To publish the config file:
php artisan vendor:publish --tag=youtube-testkit-config
Requirements
- PHP 8.3+
- Laravel 10, 11, or 12
google/apiclient^2.15
Setup
Register the real Google services in your AppServiceProvider:
use Google\Client as GoogleClient; use Google\Service\YouTube; use Google\Service\YouTubeAnalytics; use Google\Service\YouTubeReporting; public function register(): void { // Shared Google Client (configure once) $this->app->singleton(GoogleClient::class, function () { $client = new GoogleClient(); $client->setApplicationName(config('services.youtube.application_name', 'My App')); $client->setDeveloperKey(config('services.youtube.api_key')); // For Analytics/Reporting, also set OAuth credentials return $client; }); // YouTube Data API $this->app->singleton(YouTube::class, function ($app) { return new YouTube($app->make(GoogleClient::class)); }); // YouTube Analytics API $this->app->singleton(YouTubeAnalytics::class, function ($app) { return new YouTubeAnalytics($app->make(GoogleClient::class)); }); // YouTube Reporting API $this->app->singleton(YouTubeReporting::class, function ($app) { return new YouTubeReporting($app->make(GoogleClient::class)); }); }
Controllers can then type-hint any service:
use Google\Service\YouTube; use Google\Service\YouTubeAnalytics; class DashboardController extends Controller { public function index(YouTube $youtube, YouTubeAnalytics $analytics) { $videos = $youtube->videos->listVideos('snippet', ['chart' => 'mostPopular']); $stats = $analytics->reports->query([...]); return view('dashboard', compact('videos', 'stats')); } }
Testing with Pest
Base Setup
Create a base test file or add to tests/Pest.php:
use Viewtrender\Youtube\YoutubeAnalyticsApi; use Viewtrender\Youtube\YoutubeDataApi; use Viewtrender\Youtube\YoutubeReportingApi; afterEach(function () { YoutubeDataApi::reset(); YoutubeAnalyticsApi::reset(); YoutubeReportingApi::reset(); });
Import Factories
use Viewtrender\Youtube\Factories\YoutubeVideo; use Viewtrender\Youtube\Factories\YoutubeChannel; use Viewtrender\Youtube\Factories\YoutubePlaylist; use Viewtrender\Youtube\Factories\YoutubePlaylistItems; use Viewtrender\Youtube\Factories\YoutubeSearchResult; use Viewtrender\Youtube\Factories\YoutubeSubscriptions; use Viewtrender\Youtube\Factories\YoutubeComments; use Viewtrender\Youtube\Factories\YoutubeCommentThreads; use Viewtrender\Youtube\Factories\YoutubeActivities; use Viewtrender\Youtube\Factories\YoutubeCaptions; use Viewtrender\Youtube\Factories\YoutubeChannelSections; use Viewtrender\Youtube\Factories\YoutubeMembers; use Viewtrender\Youtube\Factories\YoutubeMembershipsLevels; use Viewtrender\Youtube\Factories\YoutubeI18nLanguages; use Viewtrender\Youtube\Factories\YoutubeI18nRegions; use Viewtrender\Youtube\Factories\YoutubeVideoCategories; use Viewtrender\Youtube\Factories\YoutubeVideoAbuseReportReasons; use Viewtrender\Youtube\Factories\YoutubeGuideCategories; use Viewtrender\Youtube\Factories\YoutubeThumbnails; use Viewtrender\Youtube\Factories\YoutubeWatermarks; use Viewtrender\Youtube\YoutubeDataApi;
Factory Examples — Pest
Videos
it('fetches video details', function () { YoutubeDataApi::fake([ YoutubeVideo::listWithVideos([ [ 'id' => 'dQw4w9WgXcQ', 'snippet' => [ 'title' => 'Never Gonna Give You Up', 'description' => 'Official music video', 'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw', 'channelTitle' => 'Rick Astley', 'publishedAt' => '2009-10-25T06:57:33Z', 'thumbnails' => [ 'default' => ['url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg'], 'high' => ['url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg'], ], ], 'statistics' => [ 'viewCount' => '1500000000', 'likeCount' => '15000000', 'commentCount' => '3000000', ], 'contentDetails' => [ 'duration' => 'PT3M33S', 'dimension' => '2d', 'definition' => 'hd', ], ], ]), ]); $response = $this->getJson('/api/videos/dQw4w9WgXcQ'); $response->assertOk() ->assertJsonPath('title', 'Never Gonna Give You Up') ->assertJsonPath('statistics.viewCount', '1500000000'); YoutubeDataApi::assertListedVideos(); YoutubeDataApi::assertSentCount(1); }); it('handles video not found', function () { YoutubeDataApi::fake([ YoutubeVideo::empty(), ]); $response = $this->getJson('/api/videos/nonexistent'); $response->assertNotFound(); });
Channels
it('fetches channel details', function () { YoutubeDataApi::fake([ YoutubeChannel::listWithChannels([ [ 'id' => 'UCuAXFkgsw1L7xaCfnd5JJOw', 'snippet' => [ 'title' => 'Rick Astley', 'description' => 'Official channel', 'customUrl' => '@RickAstleyYT', 'publishedAt' => '2006-09-19T01:03:26Z', 'thumbnails' => [ 'default' => ['url' => 'https://yt3.ggpht.com/example/default.jpg'], ], 'country' => 'GB', ], 'statistics' => [ 'viewCount' => '2000000000', 'subscriberCount' => '4500000', 'videoCount' => '150', ], 'brandingSettings' => [ 'channel' => [ 'title' => 'Rick Astley', 'keywords' => 'music pop 80s', ], ], ], ]), ]); $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw'); $response->assertOk() ->assertJsonPath('snippet.title', 'Rick Astley') ->assertJsonPath('statistics.subscriberCount', '4500000'); YoutubeDataApi::assertListedChannels(); });
Playlists
it('fetches user playlists', function () { YoutubeDataApi::fake([ YoutubePlaylist::listWithPlaylists([ [ 'id' => 'PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf', 'snippet' => [ 'title' => 'My Favorite Videos', 'description' => 'A collection of favorites', 'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw', 'publishedAt' => '2020-01-15T12:00:00Z', 'thumbnails' => [ 'default' => ['url' => 'https://i.ytimg.com/vi/example/default.jpg'], ], ], 'contentDetails' => [ 'itemCount' => 25, ], 'status' => [ 'privacyStatus' => 'public', ], ], [ 'id' => 'PLrAXtmErZgOeiKm4sgNOknGvNjby9efde', 'snippet' => [ 'title' => 'Watch Later', 'description' => 'Videos to watch', ], 'contentDetails' => [ 'itemCount' => 100, ], ], ]), ]); $response = $this->getJson('/api/playlists'); $response->assertOk() ->assertJsonCount(2, 'items') ->assertJsonPath('items.0.snippet.title', 'My Favorite Videos'); YoutubeDataApi::assertListedPlaylists(); });
Playlist Items
it('fetches videos in a playlist', function () { YoutubeDataApi::fake([ YoutubePlaylistItems::listWithPlaylistItems([ [ 'id' => 'UExmWEZ...', 'snippet' => [ 'title' => 'First Video', 'description' => 'Description of first video', 'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw', 'playlistId' => 'PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf', 'position' => 0, 'resourceId' => [ 'kind' => 'youtube#video', 'videoId' => 'dQw4w9WgXcQ', ], 'thumbnails' => [ 'default' => ['url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg'], ], ], 'contentDetails' => [ 'videoId' => 'dQw4w9WgXcQ', 'videoPublishedAt' => '2009-10-25T06:57:33Z', ], ], [ 'snippet' => [ 'title' => 'Second Video', 'position' => 1, 'resourceId' => [ 'kind' => 'youtube#video', 'videoId' => 'abc123xyz', ], ], ], ]), ]); $response = $this->getJson('/api/playlists/PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf/items'); $response->assertOk() ->assertJsonCount(2, 'items') ->assertJsonPath('items.0.snippet.position', 0); });
Search Results
it('searches for videos', function () { YoutubeDataApi::fake([ YoutubeSearchResult::listWithResults([ [ 'id' => [ 'kind' => 'youtube#video', 'videoId' => 'dQw4w9WgXcQ', ], 'snippet' => [ 'title' => 'Never Gonna Give You Up', 'description' => 'Official music video', 'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw', 'channelTitle' => 'Rick Astley', 'publishedAt' => '2009-10-25T06:57:33Z', 'liveBroadcastContent' => 'none', ], ], [ 'id' => [ 'kind' => 'youtube#channel', 'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw', ], 'snippet' => [ 'title' => 'Rick Astley', 'description' => 'Official channel', ], ], ]), ]); $response = $this->getJson('/api/search?q=rick+astley'); $response->assertOk() ->assertJsonCount(2, 'items') ->assertJsonPath('items.0.id.videoId', 'dQw4w9WgXcQ'); YoutubeDataApi::assertSearched(); });
Subscriptions
it('fetches channel subscriptions', function () { YoutubeDataApi::fake([ YoutubeSubscriptions::listWithSubscriptions([ [ 'id' => 'subscription123', 'snippet' => [ 'title' => 'PewDiePie', 'description' => 'Gaming and entertainment', 'channelId' => 'UC-lHJZR3Gqxm24_Vd_AJ5Yw', 'resourceId' => [ 'kind' => 'youtube#channel', 'channelId' => 'UC-lHJZR3Gqxm24_Vd_AJ5Yw', ], 'thumbnails' => [ 'default' => ['url' => 'https://yt3.ggpht.com/pewdiepie/default.jpg'], ], ], 'contentDetails' => [ 'totalItemCount' => 4500, 'newItemCount' => 3, ], ], [ 'snippet' => [ 'title' => 'MrBeast', 'resourceId' => [ 'kind' => 'youtube#channel', 'channelId' => 'UCX6OQ3DkcsbYNE6H8uQQuVA', ], ], ], ]), ]); $response = $this->getJson('/api/subscriptions'); $response->assertOk() ->assertJsonCount(2, 'items') ->assertJsonPath('items.0.snippet.title', 'PewDiePie'); });
Comments
it('fetches video comments', function () { YoutubeDataApi::fake([ YoutubeComments::listWithComments([ [ 'id' => 'comment123', 'snippet' => [ 'videoId' => 'dQw4w9WgXcQ', 'textDisplay' => 'This song is a masterpiece!', 'textOriginal' => 'This song is a masterpiece!', 'authorDisplayName' => 'MusicFan123', 'authorChannelId' => ['value' => 'UCxxx'], 'authorProfileImageUrl' => 'https://yt3.ggpht.com/user/default.jpg', 'likeCount' => 1500, 'publishedAt' => '2023-01-15T10:30:00Z', 'updatedAt' => '2023-01-15T10:30:00Z', ], ], [ 'snippet' => [ 'textDisplay' => 'Classic!', 'authorDisplayName' => 'RetroLover', 'likeCount' => 500, ], ], ]), ]); $response = $this->getJson('/api/videos/dQw4w9WgXcQ/comments'); $response->assertOk() ->assertJsonPath('items.0.snippet.textDisplay', 'This song is a masterpiece!'); });
Comment Threads
it('fetches comment threads with replies', function () { YoutubeDataApi::fake([ YoutubeCommentThreads::listWithCommentThreads([ [ 'id' => 'thread123', 'snippet' => [ 'videoId' => 'dQw4w9WgXcQ', 'topLevelComment' => [ 'id' => 'comment123', 'snippet' => [ 'textDisplay' => 'Best song ever!', 'authorDisplayName' => 'TopCommenter', 'likeCount' => 5000, ], ], 'canReply' => true, 'totalReplyCount' => 50, 'isPublic' => true, ], 'replies' => [ 'comments' => [ [ 'snippet' => [ 'textDisplay' => 'I agree!', 'authorDisplayName' => 'Replier1', ], ], ], ], ], ]), ]); $response = $this->getJson('/api/videos/dQw4w9WgXcQ/comment-threads'); $response->assertOk() ->assertJsonPath('items.0.snippet.totalReplyCount', 50); });
Activities
it('fetches channel activity feed', function () { YoutubeDataApi::fake([ YoutubeActivities::listWithActivities([ [ 'id' => 'activity123', 'snippet' => [ 'title' => 'Uploaded: New Music Video', 'description' => 'Check out my new video', 'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw', 'channelTitle' => 'Rick Astley', 'type' => 'upload', 'publishedAt' => '2024-01-15T12:00:00Z', 'thumbnails' => [ 'default' => ['url' => 'https://i.ytimg.com/vi/newvideo/default.jpg'], ], ], 'contentDetails' => [ 'upload' => [ 'videoId' => 'newvideo123', ], ], ], [ 'snippet' => [ 'title' => 'Liked: Amazing Cover', 'type' => 'like', ], 'contentDetails' => [ 'like' => [ 'resourceId' => [ 'kind' => 'youtube#video', 'videoId' => 'cover123', ], ], ], ], ]), ]); $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw/activities'); $response->assertOk() ->assertJsonPath('items.0.snippet.type', 'upload'); });
Captions
it('fetches video captions', function () { YoutubeDataApi::fake([ YoutubeCaptions::listWithCaptions([ [ 'id' => 'caption123', 'snippet' => [ 'videoId' => 'dQw4w9WgXcQ', 'language' => 'en', 'name' => 'English', 'audioTrackType' => 'primary', 'trackKind' => 'standard', 'isDraft' => false, 'isAutoSynced' => false, 'isCC' => false, 'status' => 'serving', ], ], [ 'snippet' => [ 'videoId' => 'dQw4w9WgXcQ', 'language' => 'es', 'name' => 'Spanish', 'trackKind' => 'standard', ], ], ]), ]); $response = $this->getJson('/api/videos/dQw4w9WgXcQ/captions'); $response->assertOk() ->assertJsonCount(2, 'items') ->assertJsonPath('items.0.snippet.language', 'en'); });
Channel Sections
it('fetches channel sections', function () { YoutubeDataApi::fake([ YoutubeChannelSections::listWithChannelSections([ [ 'id' => 'section123', 'snippet' => [ 'type' => 'singlePlaylist', 'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw', 'title' => 'Popular Uploads', 'position' => 0, ], 'contentDetails' => [ 'playlists' => ['PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf'], ], ], [ 'snippet' => [ 'type' => 'recentActivity', 'title' => 'Recent Activity', 'position' => 1, ], ], ]), ]); $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw/sections'); $response->assertOk() ->assertJsonPath('items.0.snippet.type', 'singlePlaylist'); });
Members (OAuth Required)
it('fetches channel members', function () { YoutubeDataApi::fake([ YoutubeMembers::listWithMembers([ [ 'id' => 'member123', 'snippet' => [ 'creatorChannelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw', 'memberDetails' => [ 'channelId' => 'UCfan123', 'channelUrl' => 'https://youtube.com/channel/UCfan123', 'displayName' => 'SuperFan', 'profileImageUrl' => 'https://yt3.ggpht.com/fan/default.jpg', ], 'membershipsDetails' => [ 'highestAccessibleLevel' => 'level1', 'highestAccessibleLevelDisplayName' => 'Bronze Member', 'memberSince' => '2023-06-01T00:00:00Z', 'memberTotalDurationMonths' => 8, ], ], ], ]), ]); $response = $this->getJson('/api/channel/members'); $response->assertOk() ->assertJsonPath('items.0.snippet.memberDetails.displayName', 'SuperFan'); });
Membership Levels (OAuth Required)
it('fetches membership levels', function () { YoutubeDataApi::fake([ YoutubeMembershipsLevels::listWithLevels([ [ 'id' => 'level1', 'snippet' => [ 'creatorChannelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw', 'levelDetails' => [ 'displayName' => 'Bronze Member', ], ], ], [ 'id' => 'level2', 'snippet' => [ 'levelDetails' => [ 'displayName' => 'Silver Member', ], ], ], [ 'id' => 'level3', 'snippet' => [ 'levelDetails' => [ 'displayName' => 'Gold Member', ], ], ], ]), ]); $response = $this->getJson('/api/channel/membership-levels'); $response->assertOk() ->assertJsonCount(3, 'items'); });
I18n Languages
it('fetches supported languages', function () { YoutubeDataApi::fake([ YoutubeI18nLanguages::listWithLanguages([ [ 'id' => 'en', 'snippet' => [ 'hl' => 'en', 'name' => 'English', ], ], [ 'id' => 'es', 'snippet' => [ 'hl' => 'es', 'name' => 'Spanish', ], ], [ 'id' => 'ja', 'snippet' => [ 'hl' => 'ja', 'name' => 'Japanese', ], ], ]), ]); $response = $this->getJson('/api/languages'); $response->assertOk() ->assertJsonCount(3, 'items') ->assertJsonPath('items.0.snippet.name', 'English'); });
I18n Regions
it('fetches supported regions', function () { YoutubeDataApi::fake([ YoutubeI18nRegions::listWithRegions([ [ 'id' => 'US', 'snippet' => [ 'gl' => 'US', 'name' => 'United States', ], ], [ 'id' => 'GB', 'snippet' => [ 'gl' => 'GB', 'name' => 'United Kingdom', ], ], [ 'id' => 'JP', 'snippet' => [ 'gl' => 'JP', 'name' => 'Japan', ], ], ]), ]); $response = $this->getJson('/api/regions'); $response->assertOk() ->assertJsonCount(3, 'items') ->assertJsonPath('items.0.snippet.name', 'United States'); });
Video Categories
it('fetches video categories', function () { YoutubeDataApi::fake([ YoutubeVideoCategories::listWithVideoCategories([ [ 'id' => '10', 'snippet' => [ 'channelId' => 'UCBR8-60-B28hp2BmDPdntcQ', 'title' => 'Music', 'assignable' => true, ], ], [ 'id' => '20', 'snippet' => [ 'title' => 'Gaming', 'assignable' => true, ], ], [ 'id' => '22', 'snippet' => [ 'title' => 'People & Blogs', 'assignable' => true, ], ], ]), ]); $response = $this->getJson('/api/video-categories?regionCode=US'); $response->assertOk() ->assertJsonPath('items.0.snippet.title', 'Music'); });
Video Abuse Report Reasons
it('fetches abuse report reasons', function () { YoutubeDataApi::fake([ YoutubeVideoAbuseReportReasons::listWithReasons([ [ 'id' => 'S', 'snippet' => [ 'label' => 'Spam or misleading', 'secondaryReasons' => [ ['id' => 'S.1', 'label' => 'Mass advertising'], ['id' => 'S.2', 'label' => 'Misleading thumbnail'], ], ], ], [ 'id' => 'V', 'snippet' => [ 'label' => 'Violent or repulsive content', ], ], ]), ]); $response = $this->getJson('/api/abuse-report-reasons'); $response->assertOk() ->assertJsonPath('items.0.snippet.label', 'Spam or misleading'); });
Thumbnails (Write-Only)
it('uploads a video thumbnail', function () { YoutubeDataApi::fake([ YoutubeThumbnails::setWithThumbnail([ 'default' => [ 'url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg', 'width' => 120, 'height' => 90, ], 'medium' => [ 'url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg', 'width' => 320, 'height' => 180, ], 'high' => [ 'url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 'width' => 480, 'height' => 360, ], ]), ]); $response = $this->postJson('/api/videos/dQw4w9WgXcQ/thumbnail', [ 'thumbnail' => UploadedFile::fake()->image('thumbnail.jpg'), ]); $response->assertOk() ->assertJsonPath('items.0.default.url', 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg'); });
Watermarks (Write-Only)
it('sets channel watermark', function () { YoutubeDataApi::fake([ YoutubeWatermarks::setWithWatermark([ 'timing' => [ 'type' => 'offsetFromStart', 'offsetMs' => 15000, 'durationMs' => 0, ], 'position' => [ 'type' => 'corner', 'cornerPosition' => 'topRight', ], 'imageUrl' => 'https://example.com/watermark.png', 'imageBytes' => 'base64data...', ]), ]); $response = $this->postJson('/api/channel/watermark', [ 'image' => UploadedFile::fake()->image('watermark.png'), ]); $response->assertOk(); });
Pagination
Two factories support multi-page responses via the HasPagination trait: YoutubePlaylistItems and YoutubeActivities. These are the Data API endpoints that return nextPageToken for iterating through large result sets.
Both expose two static methods that return array<FakeResponse> — spread them into fake([]) to queue all pages at once:
| Method | Purpose |
|---|---|
paginated(pages, perPage) |
Auto-generate items with sensible defaults |
pages(array) |
Explicit control over each page's items |
Each factory also provides a named constructor for building individual items:
YoutubePlaylistItems::playlistItem(array $overrides = [])YoutubeActivities::activity(array $overrides = [])
paginated() — auto-generated items
Generate multiple pages of fake items with a single call. Each page includes a nextPageToken except the last, and pageInfo reflects the total count.
it('syncs all playlist items across multiple pages', function () { YoutubeDataApi::fake([ ...YoutubePlaylistItems::paginated(pages: 3, perPage: 5), ]); // Your service will make 3 requests, each returning 5 items. // The first two responses include nextPageToken; the last does not. $response = $this->postJson('/api/playlists/PLrAXtmErZgOe/sync'); $response->assertOk(); YoutubeDataApi::assertSentCount(3); });
Manual pagination loop
If your code paginates through results directly (e.g., in a job or service class), you can test the full loop:
it('collects all items across pages', function () { YoutubeDataApi::fake([ ...YoutubePlaylistItems::paginated(pages: 3, perPage: 5), ]); $youtube = YoutubeDataApi::youtube(); $pageToken = null; $allItems = []; do { $response = $youtube->playlistItems->listPlaylistItems( 'snippet', ['playlistId' => 'PLxxx', 'pageToken' => $pageToken] ); $allItems = array_merge($allItems, $response->getItems()); $pageToken = $response->getNextPageToken(); } while ($pageToken !== null); expect($allItems)->toHaveCount(15); });
pages() — explicit items per page
For full control over each page's contents, pass an array of pages, where each page is an array of item overrides. Use the named constructors (::playlistItem(), ::activity()) or pass raw override arrays.
it('handles paginated activity feed with specific items', function () { YoutubeDataApi::fake([ ...YoutubeActivities::pages([ // Page 1 — has nextPageToken [ YoutubeActivities::activity([ 'snippet' => ['title' => 'Uploaded: First Video', 'type' => 'upload'], 'contentDetails' => ['upload' => ['videoId' => 'vid1']], ]), ], // Page 2 — last page, no nextPageToken [ YoutubeActivities::activity([ 'snippet' => ['title' => 'Liked: Some Video', 'type' => 'like'], ]), ], ]), ]); $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw/activities/all'); $response->assertOk(); YoutubeDataApi::assertSentCount(2); });
You can also pass raw arrays to pages() — they'll be merged with fixture defaults:
YoutubeDataApi::fake([ ...YoutubePlaylistItems::pages([ // Page 1 [ ['snippet' => ['title' => 'First Video', 'resourceId' => ['videoId' => 'vid1']]], ['snippet' => ['title' => 'Second Video', 'resourceId' => ['videoId' => 'vid2']]], ], // Page 2 [ ['snippet' => ['title' => 'Third Video', 'resourceId' => ['videoId' => 'vid3']]], ], ]), ]);
Single page (no nextPageToken)
Passing one sub-array to pages() produces a single response with no nextPageToken — useful when you want explicit item control without multi-page behavior:
YoutubeDataApi::fake([ ...YoutubePlaylistItems::pages([ [YoutubePlaylistItems::playlistItem(['snippet' => ['title' => 'Only Video']])], ]), ]);
Testing pagination in Laravel jobs
A common pattern is testing a Laravel job that syncs all items from a paginated endpoint:
it('syncs video library across paginated playlist items', function () { // Seed the channel $channel = Channel::factory()->create(['uploads_playlist_id' => 'UUxxx']); // Queue 2 pages of playlist items YoutubeDataApi::fake([ ...YoutubePlaylistItems::paginated(pages: 2, perPage: 50), ]); // Dispatch the job SyncVideoLibraryJob::dispatchSync($channel); // Verify all 100 items were persisted expect($channel->videos()->count())->toBe(100); YoutubeDataApi::assertSentCount(2); });
Testing with PHPUnit
Base Test Case
<?php namespace Tests; use Orchestra\Testbench\TestCase as BaseTestCase; use Viewtrender\Youtube\YoutubeAnalyticsApi; use Viewtrender\Youtube\YoutubeDataApi; use Viewtrender\Youtube\YoutubeReportingApi; abstract class TestCase extends BaseTestCase { protected function tearDown(): void { YoutubeDataApi::reset(); YoutubeAnalyticsApi::reset(); YoutubeReportingApi::reset(); parent::tearDown(); } protected function getPackageProviders($app): array { return [ \Viewtrender\Youtube\Laravel\YoutubeDataApiServiceProvider::class, ]; } }
Video Tests
<?php namespace Tests\Feature; use Tests\TestCase; use Viewtrender\Youtube\Factories\YoutubeVideo; use Viewtrender\Youtube\YoutubeDataApi; class VideoControllerTest extends TestCase { public function test_show_returns_video_details(): void { YoutubeDataApi::fake([ YoutubeVideo::listWithVideos([ [ 'id' => 'dQw4w9WgXcQ', 'snippet' => [ 'title' => 'Never Gonna Give You Up', 'description' => 'Official music video', 'channelTitle' => 'Rick Astley', ], 'statistics' => [ 'viewCount' => '1500000000', 'likeCount' => '15000000', ], ], ]), ]); $response = $this->getJson('/api/videos/dQw4w9WgXcQ'); $response->assertOk(); $response->assertJsonPath('title', 'Never Gonna Give You Up'); $response->assertJsonPath('statistics.viewCount', '1500000000'); YoutubeDataApi::assertListedVideos(); } public function test_show_returns_404_when_video_not_found(): void { YoutubeDataApi::fake([ YoutubeVideo::empty(), ]); $response = $this->getJson('/api/videos/nonexistent'); $response->assertNotFound(); } public function test_index_returns_multiple_videos(): void { YoutubeDataApi::fake([ YoutubeVideo::listWithVideos([ ['id' => 'video1', 'snippet' => ['title' => 'First Video']], ['id' => 'video2', 'snippet' => ['title' => 'Second Video']], ['id' => 'video3', 'snippet' => ['title' => 'Third Video']], ]), ]); $response = $this->getJson('/api/videos?ids=video1,video2,video3'); $response->assertOk(); $response->assertJsonCount(3, 'items'); } }
Channel Tests
<?php namespace Tests\Feature; use Tests\TestCase; use Viewtrender\Youtube\Factories\YoutubeChannel; use Viewtrender\Youtube\YoutubeDataApi; class ChannelControllerTest extends TestCase { public function test_show_returns_channel_details(): void { YoutubeDataApi::fake([ YoutubeChannel::listWithChannels([ [ 'id' => 'UCuAXFkgsw1L7xaCfnd5JJOw', 'snippet' => [ 'title' => 'Rick Astley', 'customUrl' => '@RickAstleyYT', 'country' => 'GB', ], 'statistics' => [ 'subscriberCount' => '4500000', 'videoCount' => '150', ], ], ]), ]); $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw'); $response->assertOk(); $response->assertJsonPath('snippet.title', 'Rick Astley'); YoutubeDataApi::assertListedChannels(); } }
Search Tests
<?php namespace Tests\Feature; use Tests\TestCase; use Viewtrender\Youtube\Factories\YoutubeSearchResult; use Viewtrender\Youtube\YoutubeDataApi; class SearchControllerTest extends TestCase { public function test_search_returns_results(): void { YoutubeDataApi::fake([ YoutubeSearchResult::listWithResults([ [ 'id' => ['kind' => 'youtube#video', 'videoId' => 'abc123'], 'snippet' => ['title' => 'Search Result 1'], ], [ 'id' => ['kind' => 'youtube#video', 'videoId' => 'def456'], 'snippet' => ['title' => 'Search Result 2'], ], ]), ]); $response = $this->getJson('/api/search?q=test+query'); $response->assertOk(); $response->assertJsonCount(2, 'items'); YoutubeDataApi::assertSearched(); } public function test_search_returns_empty_for_no_results(): void { YoutubeDataApi::fake([ YoutubeSearchResult::empty(), ]); $response = $this->getJson('/api/search?q=gibberish+nonsense+xyz'); $response->assertOk(); $response->assertJsonCount(0, 'items'); } }
Subscription Tests
<?php namespace Tests\Feature; use Tests\TestCase; use Viewtrender\Youtube\Factories\YoutubeSubscriptions; use Viewtrender\Youtube\YoutubeDataApi; class SubscriptionControllerTest extends TestCase { public function test_index_returns_user_subscriptions(): void { YoutubeDataApi::fake([ YoutubeSubscriptions::listWithSubscriptions([ [ 'snippet' => [ 'title' => 'PewDiePie', 'resourceId' => [ 'channelId' => 'UC-lHJZR3Gqxm24_Vd_AJ5Yw', ], ], ], [ 'snippet' => [ 'title' => 'MrBeast', 'resourceId' => [ 'channelId' => 'UCX6OQ3DkcsbYNE6H8uQQuVA', ], ], ], ]), ]); $response = $this->getJson('/api/subscriptions'); $response->assertOk(); $response->assertJsonCount(2, 'items'); } }
Complete Feature Test Example
<?php namespace Tests\Feature; use Tests\TestCase; use Viewtrender\Youtube\Factories\YoutubeVideo; use Viewtrender\Youtube\Factories\YoutubeChannel; use Viewtrender\Youtube\Factories\YoutubePlaylist; use Viewtrender\Youtube\Factories\YoutubePlaylistItems; use Viewtrender\Youtube\Responses\ErrorResponse; use Viewtrender\Youtube\YoutubeDataApi; class YouTubeIntegrationTest extends TestCase { public function test_dashboard_loads_channel_and_recent_videos(): void { // Queue multiple responses for a complex page load YoutubeDataApi::fake([ // First call: Get channel details YoutubeChannel::listWithChannels([ [ 'id' => 'UCuAXFkgsw1L7xaCfnd5JJOw', 'snippet' => ['title' => 'My Channel'], 'statistics' => ['subscriberCount' => '10000'], ], ]), // Second call: Get recent uploads playlist YoutubePlaylist::listWithPlaylists([ [ 'id' => 'UUuAXFkgsw1L7xaCfnd5JJOw', 'snippet' => ['title' => 'Uploads'], ], ]), // Third call: Get playlist items YoutubePlaylistItems::listWithPlaylistItems([ ['snippet' => ['title' => 'Latest Video', 'position' => 0]], ['snippet' => ['title' => 'Previous Video', 'position' => 1]], ]), // Fourth call: Get video statistics YoutubeVideo::listWithVideos([ ['id' => 'vid1', 'statistics' => ['viewCount' => '5000']], ['id' => 'vid2', 'statistics' => ['viewCount' => '3000']], ]), ]); $response = $this->get('/dashboard'); $response->assertOk(); $response->assertSee('My Channel'); $response->assertSee('Latest Video'); YoutubeDataApi::assertSentCount(4); } public function test_handles_api_quota_exceeded(): void { YoutubeDataApi::fake([ ErrorResponse::quotaExceeded('Daily quota exhausted'), ]); $response = $this->getJson('/api/videos/any'); $response->assertStatus(500); $response->assertJsonPath('error', 'YouTube API quota exceeded'); } public function test_handles_unauthorized_access(): void { YoutubeDataApi::fake([ ErrorResponse::unauthorized('Invalid API key'), ]); $response = $this->getJson('/api/videos/any'); $response->assertStatus(401); } public function test_prevents_stray_requests(): void { $fake = YoutubeDataApi::fake([ YoutubeVideo::list(), ]); $fake->preventStrayRequests(); // First request succeeds $this->getJson('/api/videos/abc'); // Second request throws because no more responses queued $this->expectException(\Viewtrender\Youtube\Exceptions\StrayRequestException::class); $this->getJson('/api/videos/xyz'); } }
YouTube Analytics API
For on-demand metrics queries — dashboards, real-time stats, custom date ranges.
Base Test Case (Analytics)
<?php namespace Tests; use Orchestra\Testbench\TestCase as BaseTestCase; use Viewtrender\Youtube\YoutubeAnalyticsApi; abstract class AnalyticsTestCase extends BaseTestCase { protected function tearDown(): void { YoutubeAnalyticsApi::reset(); parent::tearDown(); } protected function getPackageProviders($app): array { return [ \Viewtrender\Youtube\Laravel\YoutubeDataApiServiceProvider::class, ]; } }
Channel Analytics Tests
<?php namespace Tests\Feature; use Tests\AnalyticsTestCase; use Viewtrender\Youtube\Factories\AnalyticsQueryResponse; use Viewtrender\Youtube\YoutubeAnalyticsApi; class ChannelAnalyticsTest extends AnalyticsTestCase { public function test_fetches_channel_overview_metrics(): void { YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::channelOverview([ 'views' => 500000, 'estimatedMinutesWatched' => 1500000, 'averageViewDuration' => 180, 'subscribersGained' => 1000, 'subscribersLost' => 50, ]), ]); $response = $this->getJson('/api/analytics/overview?startDate=2024-01-01&endDate=2024-01-31'); $response->assertOk(); $response->assertJsonPath('views', 500000); $response->assertJsonPath('subscribersGained', 1000); YoutubeAnalyticsApi::assertSentCount(1); } public function test_fetches_daily_metrics(): void { YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::dailyMetrics([ ['day' => '2024-01-01', 'views' => 10000, 'estimatedMinutesWatched' => 30000], ['day' => '2024-01-02', 'views' => 12000, 'estimatedMinutesWatched' => 36000], ['day' => '2024-01-03', 'views' => 11000, 'estimatedMinutesWatched' => 33000], ]), ]); $response = $this->getJson('/api/analytics/daily'); $response->assertOk(); $response->assertJsonCount(3, 'rows'); } public function test_fetches_top_videos(): void { YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::topVideos([ ['video' => 'dQw4w9WgXcQ', 'views' => 50000, 'estimatedMinutesWatched' => 150000], ['video' => 'abc123xyz', 'views' => 30000, 'estimatedMinutesWatched' => 90000], ]), ]); $response = $this->getJson('/api/analytics/top-videos'); $response->assertOk(); $response->assertJsonPath('rows.0.0', 'dQw4w9WgXcQ'); } public function test_fetches_traffic_sources(): void { YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::trafficSources([ ['source' => 'RELATED_VIDEO', 'views' => 200000], ['source' => 'YT_SEARCH', 'views' => 150000], ['source' => 'EXT_URL', 'views' => 50000], ['source' => 'SUBSCRIBER', 'views' => 30000], ]), ]); $response = $this->getJson('/api/analytics/traffic-sources'); $response->assertOk(); $response->assertJsonCount(4, 'rows'); } public function test_fetches_demographics(): void { YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::demographics([ ['ageGroup' => 'age18-24', 'gender' => 'male', 'viewerPercentage' => 25.5], ['ageGroup' => 'age18-24', 'gender' => 'female', 'viewerPercentage' => 15.2], ['ageGroup' => 'age25-34', 'gender' => 'male', 'viewerPercentage' => 22.1], ['ageGroup' => 'age25-34', 'gender' => 'female', 'viewerPercentage' => 18.3], ]), ]); $response = $this->getJson('/api/analytics/demographics'); $response->assertOk(); $response->assertJsonPath('rows.0.2', 25.5); } public function test_fetches_geography(): void { YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::geography([ ['country' => 'US', 'views' => 200000], ['country' => 'GB', 'views' => 80000], ['country' => 'CA', 'views' => 50000], ]), ]); $response = $this->getJson('/api/analytics/geography'); $response->assertOk(); $response->assertJsonPath('rows.0.0', 'US'); } public function test_fetches_content_type_breakdown(): void { YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::videoTypes([ ['video' => 'abc123', 'creatorContentType' => 'VIDEO_ON_DEMAND', 'views' => 180000], ['video' => 'def456', 'creatorContentType' => 'SHORTS', 'views' => 120000], ['video' => 'ghi789', 'creatorContentType' => 'LIVE_STREAM', 'views' => 50000], ]), ]); $response = $this->getJson('/api/analytics/content-types'); $response->assertOk(); $response->assertJsonCount(3, 'rows'); } public function test_fetches_playback_locations(): void { YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::playbackLocations(), ]); $response = $this->getJson('/api/analytics/playback-locations'); $response->assertOk(); $response->assertJsonPath('rows.0.0', 'WATCH'); } public function test_fetches_playback_locations_filtered(): void { // Column filtering: only request views metric YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::playbackLocations(metrics: ['views']), ]); $response = $this->getJson('/api/analytics/playback-locations?metrics=views'); $response->assertOk(); } public function test_fetches_operating_systems(): void { YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::operatingSystems(), ]); $response = $this->getJson('/api/analytics/operating-systems'); $response->assertOk(); $response->assertJsonPath('rows.0.0', 'WINDOWS'); } public function test_fetches_sharing_service(): void { YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::sharingService(), ]); $response = $this->getJson('/api/analytics/sharing'); $response->assertOk(); $response->assertJsonPath('rows.0.0', 'COPY_PASTE'); } public function test_fetches_device_os_combo(): void { YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::deviceOperatingSystem(), ]); $response = $this->getJson('/api/analytics/device-os'); $response->assertOk(); $response->assertJsonPath('rows.0.0', 'DESKTOP'); $response->assertJsonPath('rows.0.1', 'WINDOWS'); } public function test_fetches_audience_retention(): void { YoutubeAnalyticsApi::fake([ AnalyticsQueryResponse::audienceRetention(), ]); $response = $this->getJson('/api/analytics/retention?video=abc123'); $response->assertOk(); $response->assertJsonCount(100, 'rows'); } }
YouTube Reporting API
For bulk data exports — background jobs, historical data pipelines, scheduled reports.
Workflow: Create job → Poll for reports → Download CSV → Parse & upsert
Base Test Case (Reporting)
<?php namespace Tests; use Orchestra\Testbench\TestCase as BaseTestCase; use Viewtrender\Youtube\YoutubeReportingApi; abstract class ReportingTestCase extends BaseTestCase { protected function tearDown(): void { YoutubeReportingApi::reset(); parent::tearDown(); } protected function getPackageProviders($app): array { return [ \Viewtrender\Youtube\Laravel\YoutubeDataApiServiceProvider::class, ]; } }
Reporting Job Tests
<?php namespace Tests\Feature; use Tests\ReportingTestCase; use Viewtrender\Youtube\Factories\ReportingJob; use Viewtrender\Youtube\Factories\ReportingReport; use Viewtrender\Youtube\Factories\ReportingReportType; use Viewtrender\Youtube\Factories\ReportingMedia; use Viewtrender\Youtube\YoutubeReportingApi; class ReportingPipelineTest extends ReportingTestCase { public function test_creates_reporting_job(): void { YoutubeReportingApi::fake([ ReportingJob::create([ 'id' => 'job-123', 'reportTypeId' => 'channel_basic_a2', 'name' => 'Daily Channel Stats', ]), ]); $response = $this->postJson('/api/reporting/jobs', [ 'reportTypeId' => 'channel_basic_a2', 'name' => 'Daily Channel Stats', ]); $response->assertCreated(); $response->assertJsonPath('id', 'job-123'); YoutubeReportingApi::assertSentCount(1); } public function test_lists_reporting_jobs(): void { YoutubeReportingApi::fake([ ReportingJob::list([ ['id' => 'job-1', 'reportTypeId' => 'channel_basic_a2', 'name' => 'Daily Stats'], ['id' => 'job-2', 'reportTypeId' => 'channel_demographics_a1', 'name' => 'Demographics'], ['id' => 'job-3', 'reportTypeId' => 'channel_traffic_source_a2', 'name' => 'Traffic'], ]), ]); $response = $this->getJson('/api/reporting/jobs'); $response->assertOk(); $response->assertJsonCount(3, 'jobs'); $response->assertJsonPath('jobs.0.reportTypeId', 'channel_basic_a2'); YoutubeReportingApi::assertSentCount(1); } public function test_lists_available_reports(): void { YoutubeReportingApi::fake([ ReportingReport::list([ [ 'id' => 'report-1', 'jobId' => 'job-123', 'startTime' => '2024-01-01T00:00:00Z', 'endTime' => '2024-01-02T00:00:00Z', 'createTime' => '2024-01-02T06:00:00Z', 'downloadUrl' => 'https://youtubereporting.googleapis.com/v1/media/report-1', ], [ 'id' => 'report-2', 'jobId' => 'job-123', 'startTime' => '2024-01-02T00:00:00Z', 'endTime' => '2024-01-03T00:00:00Z', 'createTime' => '2024-01-03T06:00:00Z', 'downloadUrl' => 'https://youtubereporting.googleapis.com/v1/media/report-2', ], ]), ]); $response = $this->getJson('/api/reporting/jobs/job-123/reports'); $response->assertOk(); $response->assertJsonCount(2, 'reports'); } public function test_downloads_report_csv(): void { $csvContent = "date,channel_id,views,watch_time_minutes,average_view_duration_seconds\n" . "2024-01-01,UC123,10000,50000,300\n" . "2024-01-02,UC123,12000,60000,300\n" . "2024-01-03,UC123,11000,55000,300\n"; YoutubeReportingApi::fake([ ReportingMedia::download($csvContent), ]); $response = $this->get('/api/reporting/download/report-1'); $response->assertOk(); $response->assertHeader('Content-Type', 'text/csv'); YoutubeReportingApi::assertSentCount(1); } public function test_lists_report_types(): void { YoutubeReportingApi::fake([ ReportingReportType::list([ ['id' => 'channel_basic_a2', 'name' => 'Channel Basic'], ['id' => 'channel_demographics_a1', 'name' => 'Channel Demographics'], ['id' => 'channel_device_os_a2', 'name' => 'Channel Device/OS'], ['id' => 'channel_traffic_source_a2', 'name' => 'Channel Traffic Source'], ]), ]); $response = $this->getJson('/api/reporting/report-types'); $response->assertOk(); $response->assertJsonCount(4, 'reportTypes'); } public function test_deletes_reporting_job(): void { YoutubeReportingApi::fake([ ReportingJob::delete(), ]); $response = $this->deleteJson('/api/reporting/jobs/job-123'); $response->assertNoContent(); YoutubeReportingApi::assertSentCount(1); } }
Complete Pipeline Test
<?php namespace Tests\Feature; use Tests\TestCase; use Viewtrender\Youtube\Factories\ReportingJob; use Viewtrender\Youtube\Factories\ReportingReport; use Viewtrender\Youtube\Factories\ReportingMedia; use Viewtrender\Youtube\YoutubeReportingApi; class ReportingSyncJobTest extends TestCase { protected function tearDown(): void { YoutubeReportingApi::reset(); parent::tearDown(); } public function test_full_reporting_pipeline(): void { // Queue responses for the entire pipeline YoutubeReportingApi::fake([ // 1. List jobs to find our job ReportingJob::list([ ['id' => 'job-123', 'reportTypeId' => 'channel_basic_a2'], ]), // 2. List available reports for the job ReportingReport::list([ [ 'id' => 'report-today', 'jobId' => 'job-123', 'startTime' => '2024-01-01T00:00:00Z', 'endTime' => '2024-01-02T00:00:00Z', 'downloadUrl' => 'https://youtubereporting.googleapis.com/v1/media/report-today', ], ]), // 3. Download the report CSV ReportingMedia::download( "date,channel_id,views,watch_time_minutes\n" . "2024-01-01,UC123,10000,50000\n" ), ]); // Dispatch the sync job $this->artisan('youtube:sync-reports') ->assertSuccessful(); // Verify all three API calls were made YoutubeReportingApi::assertSentCount(3); // Verify data was stored $this->assertDatabaseHas('channel_daily_stats', [ 'date' => '2024-01-01', 'views' => 10000, ]); } }
Error Responses
Simulate API errors in your tests:
use Viewtrender\Youtube\Responses\ErrorResponse; // 404 Not Found YoutubeDataApi::fake([ErrorResponse::notFound()]); YoutubeDataApi::fake([ErrorResponse::notFound('Video not found')]); // 403 Forbidden YoutubeDataApi::fake([ErrorResponse::forbidden()]); YoutubeDataApi::fake([ErrorResponse::forbidden('Access denied')]); // 401 Unauthorized YoutubeDataApi::fake([ErrorResponse::unauthorized()]); YoutubeDataApi::fake([ErrorResponse::unauthorized('Invalid credentials')]); // 403 Quota Exceeded YoutubeDataApi::fake([ErrorResponse::quotaExceeded()]); YoutubeDataApi::fake([ErrorResponse::quotaExceeded('Daily limit reached')]); // 400 Bad Request YoutubeDataApi::fake([ErrorResponse::badRequest()]); YoutubeDataApi::fake([ErrorResponse::badRequest('Invalid parameter')]);
Assertions
// Assert a request was sent matching the callback YoutubeDataApi::assertSent(function ($request) { return str_contains($request->getUri()->getPath(), '/videos'); }); // Assert no request matched the callback YoutubeDataApi::assertNotSent(function ($request) { return str_contains($request->getUri()->getPath(), '/channels'); }); // Assert no requests were sent YoutubeDataApi::assertNothingSent(); // Assert exact number of requests YoutubeDataApi::assertSentCount(3); // Endpoint-specific assertions YoutubeDataApi::assertListedVideos(); YoutubeDataApi::assertListedChannels(); YoutubeDataApi::assertListedPlaylists(); YoutubeDataApi::assertSearched();
Configuration
// config/youtube-testkit.php return [ // Custom fixture path (null = package defaults) 'fixtures_path' => null, // Throw exception on unqueued requests 'prevent_stray_requests' => false, ];
License
MIT