supersuhyeon / React-Youtube-Clone

youtube clone & unit test, integration test, E2E test with React, Tailwindcss, youtubeAPIs, JEST, RTL, cypress

Home Page:https://darling-cheesecake-c962f9.netlify.app/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

React Youtube Clone Coding

ezgif com-gif-maker (63)
This is a Youtube clone project that helped me to practice how to communicate with a network using Axios & read open source APIs to apply to my project.
Youtube Clone Coding

Goals of the project

  1. Build components hierarchy and use React router for making a single page application
  2. Fetch Youtube's data API (mock data and real data)
  3. Use React router's components and hooks like outlet, useParams, useLocation, and useNavigate
  4. Use React-query to manage asynchronous requests and data effectively
  5. Practice TailwindCSS
  6. Testing project - Unit test, intergration test, E2E test

Languages

React, TailwindCSS, JEST, React testing library, cypress

Features

1. Build components hierarchy and use React router for making a single page application
youtube-api-readme

  1. Add a router for making a single page application in index.js
const router = createBrowserRouter([
  {
    path: "/",
    element: <App />, // the starting point of this application (top level element)
    errorElement: <NotFound></NotFound>,
    children: [
      //Outlet
      { index: true, element: <Videos></Videos> },
      { path: "/videos", element: <Videos></Videos> },
      { path: "/videos/:keyword", element: <Videos></Videos> },
      { path: "/videos/watch/:videoId", element: <VideoDetail></VideoDetail> },
    ],
  },
]);

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <RouterProvider router={router}></RouterProvider>
  </React.StrictMode>
);
  1. Each component/pages represents:
  • App: top level element
  • SearchHeader: logo and input element that user can use to search
  • Outlet: children route elements (videoes and videoDetail)
  • ThemeModeBtn: toggle button that is able to change darkmode and lightmode
  • Videos: show videos that user searched by keyword
  • VideoCard: how each video looks like (thumbnails, title, channel name, and published date)
  • VideoDetail: a video's detail page
  • ChannelInfo: the video channel's thumbnail and channel's name
  • RelatedVideos: related video list according to the video's Id
  • CommentList: reorganizes and displays the data
  • CommentAdd: adds the data based on user's input
  • Comment: clicks a like button and deletes the data

2. Fetch the Youtube data API (mock data and real data)
Before getting the real Youtube data API, I would rather have a mock data file so that I don't have to go beyond a per day limit according to Youtube's policy while testing. (YouTube Data API - Quota and Compliance Audits)

  1. Need to get your own API key - calling the API
    Every request must either specify an API key (with the key parameter) or provide an OAuth 2.0 token. Your API key is available in the Developer Console's API Access pane for your project.

  2. check the Reference page to get specific APIs and test out the url (HTTP)
    I saved the below URLs' result value into JSON files which can be used as mock data. (I recommend using postman to manage and test all API collections)

  1. Create two separate files - one for the network logic of mock data, and the other for the real YouTube API.
// api - FakeYoutube.js
export default class FakeYoutube {
  async search(keyword) {
    return keyword ? this.#searchByKeyword(keyword) : this.#mostPopular(); //private 함수 클래스 외부에서는 호출불가능
  }

  async #searchByKeyword() {
    return axios
      .get(`/videos/search.json`)
      .then((res) => res.data.items)
      .then((items) => items.map((item) => ({ ...item, id: item.id.videoId }))); //popular과 동일한 데이터 문자열로 맞춰주기
  }

  async #mostPopular() {
    return axios.get(`/videos/search.json`).then((res) => res.data.items);
  }
}
// api - Youtube.js
export default class Youtube {
  constructor() {
    this.httpClient = axios.create({
      baseURL: "https://www.googleapis.com/youtube/v3",
      params: { key: process.env.REACT_APP_YOUTUBE_API_KEY },
    });
  }

  async search(keyword) {
    return keyword ? this.#searchByKeyword(keyword) : this.#mostPopular(); //private 함수 클래스 외부에서는 호출불가능
  }

  async #searchByKeyword(keyword) {
    return this.httpClient
      .get("search", {
        params: {
          part: "snippet",
          maxResults: 25,
          type: "video",
          q: keyword,
        },
      })
      .then((res) => res.data.items)
      .then((items) => items.map((item) => ({ ...item, id: item.id.videoId }))); //popular과 동일한 데이터 문자열로 맞춰주기
  }

  async #mostPopular() {
    return this.httpClient
      .get("videos", {
        params: {
          part: "snippet",
          maxResults: 25,
          chart: "mostPopular",
        },
      })
      .then((res) => res.data.items);
  }
}
  1. Create an instance in the YoutubeApiContext and pass it to the value prop provided by the provider
//context - YoutubeApiContext.jsx
import { createContext, useContext } from "react";
import Youtube from "../api/youtube";
// import FakeYoutube from "../api/fakeYoutube";

export const YoutubeApiContext = createContext();

// const youtube = new FakeYoutube(); ---> mock data
const youtube = new Youtube(); // ---> real youtube api data

export function YoutubeApiProvider({ children }) {
  return (
    <YoutubeApiContext.Provider value={{ youtube }}>
      {children}
    </YoutubeApiContext.Provider>
  );
}

export function useYoutubeApi() {
  return useContext(YoutubeApiContext);
}
  1. By separating the network logic from the components, It is improved the readability and maintainability.
// Videos.jsx
export default function Videos() {
  const { youtube } = useYoutubeApi();
  const {
    isLoading,
    error,
    data: videos,
  } = useQuery(["videos", keyword], () => youtube.search(keyword), {
    staleTime: 1000 * 60 * 1,
  });

  return(
    //..codes
  )
}

3. Use React router's components and hooks like outlet, useParams, useLocation, useNavigate

  • Outlet: An <Outlet> should be used in parent route elements to render their child route elements. This allows the nested UI to show up when child routes are rendered. If the parent route matched exactly, it will render a child index route or nothing if there is no index route.
  • useParams: The useParams hook returns an object of key/value pairs of the dynamic params from the current URL that were matched by the <Route path>. Child routes inherit all params from their parent routes.
  • useLocation: This can be useful if you'd like to perform some side effect whenever the current location changes. (normally use with useNavigate together)
  • useNavigate: It is similar to <Link to>.
export default function Videos() {
  const { keyword } = useParams(); //{keyword:'keyword'}
  const { youtube } = useYoutubeApi(); //from YoutubeApiContext

  const {
    isLoading,
    error,
    data: videos,
  } = useQuery(["videos", keyword], () => youtube.search(keyword), {
    staleTime: 1000 * 60 * 1,
  });
  //if there is a keyword then #searchByKeyword(keyword), if not then #mostPopular()
  return (
    <>
      {videos && (
        <h1 className="text-2xl font-semibold mb-4">
          Total video numbers : {videos.length}
        </h1>
      )}
      {isLoading && <p>Loading....</p>}
      {error && <p>something is wrong😥</p>}
      {videos && (
        <ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-2 gap-y-4">
          {videos.map((video) => {
            return <VideoCard key={video.id} video={video}></VideoCard>;
          })}
        </ul>
      )}
    </>
  );
}
export default function VideoCard({ video, type }) {
  const { title, thumbnails, channelTitle, publishedAt } = video.snippet;
  // How to pass parameters while navigating a page with useNavigate()
  // 1) get useNavigate() 2) send parameters like navigate(/route,{state:{key:value, key:value, ...}})
  const navigate = useNavigate();
  const isList = type === "list";

  return (
    <li
      className={isList ? "flex gap-1 m-2" : ""}
      onClick={() => {
        navigate(`/videos/watch/${video.id}`, { state: { video: video } });
      }}
    >
      <img
        className={isList ? "w-60 mr-2" : "w-full"}
        src={thumbnails.medium.url}
        alt={title}
      />
      <div>
        <p className="font-semibold my-2 line-clamp-2">{title}</p>
        <p className="text-sm opacity-80">{channelTitle}</p>
        <p className="text-sm opacity-80">{formatAgo(publishedAt)}</p>
      </div>
    </li>
  );
}
export default function VideoDetail(){
    //how to get the parameter at the moved route
    // 1) get useLocation(), 2)get a parameter through state.key
    const {state:{video}} = useLocation()
    const {title, channelId, channelTitle, description} = video.snippet;

    return(
       <section className="flex flex-col lg:flex-row">

      <article className="basis-4/6">
      <iframe id="player"
                type="text/html"
                width="100%"
                height="640"
                src={`https://www.youtube.com/embed/${video.id}`}
                frameBorder="0"
                title={title}
                ></iframe>
}

4. Caching strategy with useQuery
Good to check data status with devtools provided by react-query
  • ChannelInfo: Normally the channel's thumbnail doesn't change frequently so I assigned staleTime a value of five minutes
  • RelatedVideos: Same as channel thumbnail so I also assigned staleTime a value of five minutes
  • Videos: a lot of new videos update on Youtube so I assigned staleTime a value of one minute
export default function ChannelInfo({ id, name }) {
  const { youtube } = useYoutubeApi();
  const { data: url } = useQuery(
    ["channel", id],
    () => youtube.channelImageURL(id),
    { staleTime: 1000 * 60 * 5 }
  );
}
export default function RelatedVideos({ id }) {
  const { youtube } = useYoutubeApi();
  const {
    isLoading,
    error,
    data: relatedVideos,
  } = useQuery(["relatedVideos", id], () => youtube.relatedVideos(id), {
    staleTime: 1000 * 60 * 5,
  });
}
export default function Videos() {
  const { youtube } = useYoutubeApi();
  const {
    isLoading,
    error,
    data: videos,
  } = useQuery(["videos", keyword], () => youtube.search(keyword), {
    staleTime: 1000 * 60 * 1,
  });
}
  1. Practice unit test, integration test, E2E test
    According to the Google Test Automation Conference, they suggested the Test Pyramid which is unit-integration-E2E. It is recommended to implement the total test weight according to the figure shown below.
   (1) End-To-End Testing (UI Testing) - 10%
   (2) Integrating Testing - 20%
   (3) Unit Testing - 70%

These are very useful APIs during the test.

  • Unit test & Integration test
    1)JEST : Mocking Methods, Matcher, snapshot testing
    2)React testing library : render(),screen(), Varient+Queries, MemoryRouter(), userEvent, waitFor()...etc
//unit test - dynamic state component
export default function InputTest() {
  const [text, setText] = useState("");
  const navigate = useNavigate();

  const onSubmitHandler = (e) => {
    e.preventDefault();
    navigate(`/albums/${text}`);
  };

  <div>
    <form onSubmit={onSubmitHandler}>
      <input
        type="text"
        placeholder="Search..."
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <button>
        <BsSearch />
      </button>
    </form>
  </div>;
}
describe("InputTest", () => {
  it("navigates to the results page when the search button is clicked", () => {
    const searchKeyword = "testing-keyword";

    render(
      // to make a react-router environment, you should use MemoryRouter
      <MemoryRouter initialEntries={["/home"]}>
        <Routes>
          <Route path="/home" element={<InputTest />}></Route>
          <Route
            path={`/albums/${searchKeyword}`}
            element={<p>{searchKeyword}</p>}
          ></Route>
        </Routes>
      </MemoryRouter>
    );
    const searchInput = screen.getByRole("textbox");
    const searchButton = screen.getByRole("button");

    userEvent.type(searchInput, searchKeyword);
    userEvent.click(searchButton); //when searchButton is clicked, it should show the second route's component.

    expect(screen.getByText(searchKeyword)).toBeInTheDocument();
  });
});
//Integration test for VideoDetail.jsx
//...
import RelatedVideos from "../../components/RelatedVideos";
import ChannelInfo from "../../components/ChannelInfo";

jest.mock("../../components/ChannelInfo");
jest.mock("../../components/RelatedVideos");

describe("VideoDetail", () => {
  afterEach(() => {
    ChannelInfo.mockReset();
    RelatedVideos.mockReset();
  });

  it("renders video item details", () => {
    render(
      withRouter(<Route path="/" element={<VideoDetail />} />, {
        state: { video: fakeVideo },
      })
    );

    const { title, channelId, channelTitle } = fakeVideo.snippet;
    expect(screen.getByTitle(title)).toBeInTheDocument();
    expect(RelatedVideos.mock.calls[0][0]).toStrictEqual({ id: fakeVideo.id }); //First argument of first called RelatedVideo's mock module
    expect(ChannelInfo.mock.calls[0][0]).toStrictEqual({
      //First argument of first called ChannelInfo's mock module
      id: channelId,
      name: channelTitle,
    });
  });
});
  • E2E test
    Cypress is an open source project and a tool for End to End (E2E) testing. The test checks the part that is directly exposed to the user from the user's point of view. Cypress is really good in terms of efficiency as you can check much faster than testing manually.
    Cypress - get(), should(), visit(), fixture(), click(), type()...etc
<reference types="cypress" />;
import "@testing-library/cypress/add-commands";
//this extends Cypress's cy commands

describe("Yotube App", () => {
  beforeEach(() => {
    //if we test with the real api from youtube, it is hard to get the same result due to the fact that the data keeps changing and in cases of emergency like Youtube's unstable servers. So to make sure of the stable test environment, I used fixture to have mock data.
    cy.intercept("GET", /(mostPopular)/g, {
      //cy.intercept(method, url, staticResponse)
      fixture: "popular.json",
    });
    cy.intercept("GET", /(search)/g, {
      fixture: "search.json",
    });
    cy.viewport(1200, 800);
    cy.visit("/");
  });

  it("renders", () => {
    cy.findByText("Youtube").should("exist");
  });

  it("shows popular video first", () => {
    cy.findByText("Popular Video").should("exist");
  });
});

Reference Links

My Korean blog about KEY API from JEST and React testing library
My Korean blog about how to use react router
My Korean blog about how to use react query
My Korean blog about difference between axios and fetch, how to use 'get' method
Dream coding
Tailwind
React router
React query
youtube data api reference
cypress fixture shortcut

Self-reflection

This project was more difficult than I expected but I enjoyed it a lot. It really helped me to improve how to read open source materials and documentation as well as get the information that I needed. Even though it took me quite a lot of time to finish due to writing notes whenever I learned a new concept from the official website, I believe it was all worth it. I'm also glad that I could apply the concepts that I learned last time such as darkmode & lightmode and saving data in storage by myself! However, there is a thing that I still couldn't figure out, which is the comment reply button feature. I feel like it is not that difficult but I had a lot of errors. So I hope to figure it out soon!

About

youtube clone & unit test, integration test, E2E test with React, Tailwindcss, youtubeAPIs, JEST, RTL, cypress

https://darling-cheesecake-c962f9.netlify.app/


Languages

Language:JavaScript 97.8%Language:HTML 1.5%Language:CSS 0.8%